perf: Phase 1+2+3 — Turbopack, Prisma select, RSC page, CSS masonry + dnd-kit

- Turbopack activé (dev: next dev --turbopack)
- NOTE_LIST_SELECT: exclut embedding (~6KB/note) des requêtes de liste
- getAllNotes/getNotes/getArchivedNotes/getNotesWithReminders optimisés
- searchNotes: filtrage DB-side au lieu de full-scan JS en mémoire
- getAllNotes: requêtes ownNotes + sharedNotes parallélisées avec Promise.all
- syncLabels: upsert en transaction () vs N boucles séquentielles
- app/(main)/page.tsx converti en Server Component (RSC)
- HomeClient: composant client hydraté avec données pré-chargées
- NoteEditor/BatchOrganizationDialog/AutoLabelSuggestionDialog: lazy-loaded avec dynamic()
- MasonryGrid: remplace Muuri par CSS grid auto-fill + @dnd-kit/sortable
- 13 packages supprimés: muuri, web-animations-js, react-masonry-css, react-grid-layout
- next.config.ts nettoyé: suppression webpack override, activation image optimization
This commit is contained in:
Sepehr Ramezani
2026-04-17 21:39:21 +02:00
parent 2eceb32fd4
commit cb8bcd13ba
15 changed files with 1877 additions and 1494 deletions

View File

@@ -1,13 +1,16 @@
/**
* Memento MCP Server - Shared Tool Definitions & Handlers
* Memento MCP Server - Optimized Tool Definitions & Handlers
*
* All tool definitions and their handler logic are centralized here.
* Both stdio (index.js) and HTTP (index-sse.js) transports use this module.
* Performance optimizations:
* - O(1) API key lookup with caching
* - Batch operations for imports
* - Parallel promise execution
* - HTTP timeout wrapper
* - N+1 query fixes
* - Connection pooling
*/
// PrismaClient is injected via registerTools() — no direct import needed here.
import { generateApiKey, listApiKeys, revokeApiKey, resolveUser, validateApiKey } from './auth.js';
import { generateApiKey, listApiKeys, revokeApiKey, resolveUser, validateApiKey, clearAuthCaches } from './auth.js';
import {
CallToolRequestSchema,
@@ -16,9 +19,40 @@ import {
McpError,
} from '@modelcontextprotocol/sdk/types.js';
// ─── Configuration ─────────────────────────────────────────────────────────
const DEFAULT_TIMEOUT = 10000; // 10 seconds for HTTP requests
const DEFAULT_SEARCH_LIMIT = 50;
const DEFAULT_NOTES_LIMIT = 100;
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Fetch with timeout wrapper
* Prevents hanging on slow/unresponsive endpoints
*/
async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeoutMs}ms`);
}
throw error;
}
}
export function parseNote(dbNote) {
if (!dbNote) return null;
return {
...dbNote,
checkItems: dbNote.checkItems ?? null,
@@ -29,13 +63,14 @@ export function parseNote(dbNote) {
}
export function parseNoteLightweight(dbNote) {
if (!dbNote) return null;
const images = Array.isArray(dbNote.images) ? dbNote.images : [];
const labels = Array.isArray(dbNote.labels) ? dbNote.labels : null;
const checkItems = Array.isArray(dbNote.checkItems) ? dbNote.checkItems : [];
return {
id: dbNote.id,
title: dbNote.title,
content: dbNote.content.length > 200 ? dbNote.content.substring(0, 200) + '...' : dbNote.content,
content: dbNote.content?.length > 200 ? dbNote.content.substring(0, 200) + '...' : dbNote.content,
color: dbNote.color,
type: dbNote.type,
isPinned: dbNote.isPinned,
@@ -115,7 +150,7 @@ const toolDefinitions = [
search: { type: 'string', description: 'Filter by keyword in title/content' },
notebookId: { type: 'string', description: 'Filter by notebook ID. Use "inbox" for notes without a notebook' },
fullDetails: { type: 'boolean', description: 'Return full details including images (large payload)', default: false },
limit: { type: 'number', description: 'Max notes to return (default 100)', default: 100 },
limit: { type: 'number', description: `Max notes to return (default ${DEFAULT_NOTES_LIMIT})`, default: DEFAULT_NOTES_LIMIT },
},
},
},
@@ -572,12 +607,18 @@ export function registerTools(server, prisma, options = {}) {
// Resolve userId: if not provided, auto-detect the first user
let resolvedUserId = userId;
let userIdPromise = null;
const ensureUserId = async () => {
if (!resolvedUserId) {
const firstUser = await prisma.user.findFirst({ select: { id: true } });
if (firstUser) resolvedUserId = firstUser.id;
}
return resolvedUserId;
if (resolvedUserId) return resolvedUserId;
if (userIdPromise) return userIdPromise;
userIdPromise = prisma.user.findFirst({ select: { id: true } }).then(u => {
if (u) resolvedUserId = u.id;
return resolvedUserId;
});
return userIdPromise;
};
// ── List Tools ────────────────────────────────────────────────────────────
@@ -636,7 +677,7 @@ export function registerTools(server, prisma, options = {}) {
where.notebookId = args.notebookId === 'inbox' ? null : args.notebookId;
}
const limit = args?.limit || 100;
const limit = Math.min(args?.limit || DEFAULT_NOTES_LIMIT, 500); // Max 500
const notes = await prisma.note.findMany({
where,
orderBy: [{ isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' }],
@@ -686,15 +727,17 @@ export function registerTools(server, prisma, options = {}) {
const where = {};
if (resolvedUserId) where.userId = resolvedUserId;
const count = await prisma.note.deleteMany({ where });
if (resolvedUserId) {
await prisma.label.deleteMany({ where: { notebook: { userId: resolvedUserId } } });
await prisma.notebook.deleteMany({ where: { userId: resolvedUserId } });
} else {
await prisma.label.deleteMany({});
await prisma.notebook.deleteMany({});
}
return textResult({ success: true, deletedNotes: count.count });
const [deletedNotes] = await prisma.$transaction([
prisma.note.deleteMany({ where }),
resolvedUserId
? prisma.label.deleteMany({ where: { notebook: { userId: resolvedUserId } } })
: prisma.label.deleteMany({}),
resolvedUserId
? prisma.notebook.deleteMany({ where: { userId: resolvedUserId } })
: prisma.notebook.deleteMany({}),
]);
return textResult({ success: true, deletedNotes: deletedNotes.count });
}
case 'search_notes': {
@@ -711,7 +754,7 @@ export function registerTools(server, prisma, options = {}) {
const notes = await prisma.note.findMany({
where,
orderBy: [{ isPinned: 'desc' }, { updatedAt: 'desc' }],
take: 50,
take: DEFAULT_SEARCH_LIMIT,
});
return textResult(notes.map(parseNoteLightweight));
}
@@ -721,16 +764,19 @@ export function registerTools(server, prisma, options = {}) {
if (resolvedUserId) noteWhere.userId = resolvedUserId;
const targetNotebookId = args.notebookId || null;
const note = await prisma.note.update({
where: noteWhere,
data: { notebookId: targetNotebookId, updatedAt: new Date() },
});
// Optimized: Parallel execution
const [note, notebook] = await Promise.all([
prisma.note.update({
where: noteWhere,
data: { notebookId: targetNotebookId, updatedAt: new Date() },
}),
targetNotebookId
? prisma.notebook.findUnique({ where: { id: targetNotebookId }, select: { name: true } })
: Promise.resolve(null),
]);
let notebookName = 'Inbox';
if (targetNotebookId) {
const nb = await prisma.notebook.findUnique({ where: { id: targetNotebookId } });
if (nb) notebookName = nb.name;
}
const notebookName = notebook?.name || 'Inbox';
return textResult({
success: true,
@@ -768,20 +814,40 @@ export function registerTools(server, prisma, options = {}) {
const nbWhere = {};
if (resolvedUserId) { noteWhere.userId = resolvedUserId; nbWhere.userId = resolvedUserId; }
const notes = await prisma.note.findMany({ where: noteWhere, orderBy: { updatedAt: 'desc' } });
const notebooks = await prisma.notebook.findMany({
where: nbWhere,
include: { _count: { select: { notes: true } } },
orderBy: { order: 'asc' },
});
const labels = await prisma.label.findMany({
where: nbWhere.notebookId ? {} : {},
include: { notebook: { select: { id: true, name: true } } },
});
// Optimized: Parallel queries
const [notes, notebooks, labels] = await Promise.all([
prisma.note.findMany({
where: noteWhere,
orderBy: { updatedAt: 'desc' },
select: {
id: true,
title: true,
content: true,
color: true,
type: true,
isPinned: true,
isArchived: true,
isMarkdown: true,
size: true,
labels: true,
notebookId: true,
createdAt: true,
updatedAt: true,
},
}),
prisma.notebook.findMany({
where: nbWhere,
include: { _count: { select: { notes: true } } },
orderBy: { order: 'asc' },
}),
prisma.label.findMany({
include: { notebook: { select: { id: true, name: true, userId: true } } },
}),
]);
// If userId filtering, filter labels by user's notebooks
const filteredLabels = userId
? labels.filter(l => l.notebook && l.notebook.userId === userId)
// Filter labels by userId in memory (faster than multiple queries)
const filteredLabels = resolvedUserId
? labels.filter(l => l.notebook?.userId === resolvedUserId)
: labels;
return textResult({
@@ -824,66 +890,89 @@ export function registerTools(server, prisma, options = {}) {
const importData = args.data;
let importedNotes = 0, importedLabels = 0, importedNotebooks = 0;
// Import notebooks
if (importData.data?.notebooks) {
for (const nb of importData.data.notebooks) {
const existing = userId
? await prisma.notebook.findFirst({ where: { name: nb.name, userId: resolvedUserId } })
: await prisma.notebook.findFirst({ where: { name: nb.name } });
if (!existing) {
await prisma.notebook.create({
data: {
name: nb.name,
icon: nb.icon || '📁',
color: nb.color || '#3B82F6',
...(resolvedUserId ? { userId: resolvedUserId } : {}),
},
});
importedNotebooks++;
}
}
// OPTIMIZED: Batch operations with Promise.all for notebooks
if (importData.data?.notebooks?.length > 0) {
const existingNotebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
select: { name: true },
});
const existingNames = new Set(existingNotebooks.map(nb => nb.name));
const notebooksToCreate = importData.data.notebooks
.filter(nb => !existingNames.has(nb.name))
.map(nb => prisma.notebook.create({
data: {
name: nb.name,
icon: nb.icon || '📁',
color: nb.color || '#3B82F6',
...(resolvedUserId ? { userId: resolvedUserId } : {}),
},
}));
await Promise.all(notebooksToCreate);
importedNotebooks = notebooksToCreate.length;
}
// Import labels
if (importData.data?.labels) {
// OPTIMIZED: Batch labels
if (importData.data?.labels?.length > 0) {
const notebooks = await prisma.notebook.findMany({
where: resolvedUserId ? { userId: resolvedUserId } : {},
select: { id: true },
});
const notebookIds = new Set(notebooks.map(nb => nb.id));
const existingLabels = await prisma.label.findMany({
where: { notebookId: { in: Array.from(notebookIds) } },
select: { name: true, notebookId: true },
});
const existingLabelKeys = new Set(existingLabels.map(l => `${l.notebookId}:${l.name}`));
const labelsToCreate = [];
for (const label of importData.data.labels) {
const nbWhere2 = { name: label.notebookId }; // We need to find notebook by ID
const notebook = label.notebookId
? await prisma.notebook.findUnique({ where: { id: label.notebookId } })
: null;
if (notebook) {
const existing = await prisma.label.findFirst({
where: { name: label.name, notebookId: notebook.id },
});
if (!existing) {
await prisma.label.create({
data: { name: label.name, color: label.color, notebookId: notebook.id },
});
importedLabels++;
if (label.notebookId && notebookIds.has(label.notebookId)) {
const key = `${label.notebookId}:${label.name}`;
if (!existingLabelKeys.has(key)) {
labelsToCreate.push(prisma.label.create({
data: { name: label.name, color: label.color, notebookId: label.notebookId },
}));
}
}
}
await Promise.all(labelsToCreate);
importedLabels = labelsToCreate.length;
}
// Import notes
if (importData.data?.notes) {
for (const note of importData.data.notes) {
await prisma.note.create({
data: {
title: note.title,
content: note.content,
color: note.color || 'default',
type: note.type || 'text',
isPinned: note.isPinned || false,
isArchived: note.isArchived || false,
isMarkdown: note.isMarkdown || false,
size: note.size || 'small',
labels: note.labels ?? null,
notebookId: note.notebookId || null,
...(resolvedUserId ? { userId: resolvedUserId } : {}),
},
// OPTIMIZED: Batch notes with createMany if available, else Promise.all
if (importData.data?.notes?.length > 0) {
const notesData = importData.data.notes.map(note => ({
title: note.title,
content: note.content,
color: note.color || 'default',
type: note.type || 'text',
isPinned: note.isPinned || false,
isArchived: note.isArchived || false,
isMarkdown: note.isMarkdown || false,
size: note.size || 'small',
labels: note.labels ?? null,
notebookId: note.notebookId || null,
...(resolvedUserId ? { userId: resolvedUserId } : {}),
}));
// Try createMany first (faster), fall back to Promise.all
try {
const result = await prisma.note.createMany({
data: notesData,
skipDuplicates: true,
});
importedNotes++;
importedNotes = result.count;
} catch {
// Fallback to individual creates
const creates = notesData.map(data =>
prisma.note.create({ data }).catch(() => null)
);
const results = await Promise.all(creates);
importedNotes = results.filter(r => r !== null).length;
}
}
@@ -977,22 +1066,34 @@ export function registerTools(server, prisma, options = {}) {
if (resolvedUserId) where.userId = resolvedUserId;
// Move notes to inbox before deleting
await prisma.note.updateMany({
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
data: { notebookId: null },
});
await prisma.notebook.delete({ where });
await prisma.$transaction([
prisma.note.updateMany({
where: { notebookId: args.id, ...(resolvedUserId ? { userId: resolvedUserId } : {}) },
data: { notebookId: null },
}),
prisma.notebook.delete({ where }),
]);
return textResult({ success: true, message: 'Notebook deleted, notes moved to Inbox' });
}
case 'reorder_notebooks': {
const ids = args.notebookIds;
// Verify ownership
for (const id of ids) {
const where = { id };
if (resolvedUserId) where.userId = resolvedUserId;
const nb = await prisma.notebook.findUnique({ where });
if (!nb) throw new McpError(ErrorCode.InvalidRequest, `Notebook ${id} not found`);
// Optimized: Verify ownership in one query
const where = { id: { in: ids } };
if (resolvedUserId) where.userId = resolvedUserId;
const existingNotebooks = await prisma.notebook.findMany({
where,
select: { id: true },
});
const existingIds = new Set(existingNotebooks.map(nb => nb.id));
const missingIds = ids.filter(id => !existingIds.has(id));
if (missingIds.length > 0) {
throw new McpError(ErrorCode.InvalidRequest, `Notebooks not found: ${missingIds.join(', ')}`);
}
await prisma.$transaction(
@@ -1004,7 +1105,7 @@ export function registerTools(server, prisma, options = {}) {
}
// ═══════════════════════════════════════════════════════
// LABELS
// LABELS - OPTIMIZED to fix N+1 query
// ═══════════════════════════════════════════════════════
case 'create_label': {
const existing = await prisma.label.findFirst({
@@ -1026,22 +1127,20 @@ export function registerTools(server, prisma, options = {}) {
const where = {};
if (args?.notebookId) where.notebookId = args.notebookId;
let labels = await prisma.label.findMany({
// OPTIMIZED: Single query with include, then filter in memory
const labels = await prisma.label.findMany({
where,
include: { notebook: { select: { id: true, name: true } } },
include: { notebook: { select: { id: true, name: true, userId: true } } },
orderBy: { name: 'asc' },
});
// Filter by userId if set
// Filter by userId in memory (much faster than N+1 queries)
let filteredLabels = labels;
if (resolvedUserId) {
const userNbIds = (await prisma.notebook.findMany({
where: { userId: resolvedUserId },
select: { id: true },
})).map(nb => nb.id);
labels = labels.filter(l => userNbIds.includes(l.notebookId));
filteredLabels = labels.filter(l => l.notebook?.userId === resolvedUserId);
}
return textResult(labels);
return textResult(filteredLabels);
}
case 'update_label': {
@@ -1062,11 +1161,11 @@ export function registerTools(server, prisma, options = {}) {
}
// ═══════════════════════════════════════════════════════
// AI TOOLS (direct database / API calls)
// AI TOOLS - OPTIMIZED with timeout
// ═══════════════════════════════════════════════════════
case 'generate_title_suggestions': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const resp = await fetch(`${appBaseUrl}/api/ai/title-suggestions`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/title-suggestions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: args.content }),
@@ -1078,7 +1177,7 @@ export function registerTools(server, prisma, options = {}) {
case 'reformulate_text': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const resp = await fetch(`${appBaseUrl}/api/ai/reformulate`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/reformulate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: args.text, option: args.option }),
@@ -1090,7 +1189,7 @@ export function registerTools(server, prisma, options = {}) {
case 'generate_tags': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const resp = await fetch(`${appBaseUrl}/api/ai/tags`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: args.content, notebookId: args.notebookId, language: args.language || 'en' }),
@@ -1102,7 +1201,7 @@ export function registerTools(server, prisma, options = {}) {
case 'suggest_notebook': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const resp = await fetch(`${appBaseUrl}/api/ai/suggest-notebook`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/suggest-notebook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteContent: args.noteContent, language: args.language || 'en' }),
@@ -1114,7 +1213,7 @@ export function registerTools(server, prisma, options = {}) {
case 'get_notebook_summary': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const resp = await fetch(`${appBaseUrl}/api/ai/notebook-summary`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/notebook-summary`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId: args.notebookId, language: args.language || 'en' }),
@@ -1126,7 +1225,7 @@ export function registerTools(server, prisma, options = {}) {
case 'get_memory_echo': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const resp = await fetch(`${appBaseUrl}/api/ai/echo`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/echo`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
@@ -1137,7 +1236,7 @@ export function registerTools(server, prisma, options = {}) {
case 'get_note_connections': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const params = new URLSearchParams({ noteId: args.noteId, page: String(args.page || 1), limit: String(Math.min(args.limit || 10, 50)) });
const resp = await fetch(`${appBaseUrl}/api/ai/echo/connections?${params}`);
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/echo/connections?${params}`);
const data = await resp.json();
if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Connection fetch failed');
return textResult(data);
@@ -1145,7 +1244,7 @@ export function registerTools(server, prisma, options = {}) {
case 'dismiss_connection': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
const resp = await fetch(`${appBaseUrl}/api/ai/echo/dismiss`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/echo/dismiss`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteId: args.noteId, connectedNoteId: args.connectedNoteId }),
@@ -1160,7 +1259,7 @@ export function registerTools(server, prisma, options = {}) {
if (!args.noteIds || args.noteIds.length < 2) {
throw new McpError(ErrorCode.InvalidRequest, 'At least 2 note IDs required');
}
const resp = await fetch(`${appBaseUrl}/api/ai/echo/fusion`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/echo/fusion`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds: args.noteIds, prompt: args.prompt }),
@@ -1173,7 +1272,7 @@ export function registerTools(server, prisma, options = {}) {
case 'batch_organize': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
if (args.action === 'create_plan') {
const resp = await fetch(`${appBaseUrl}/api/ai/batch-organize`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/batch-organize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: args.language || 'en' }),
@@ -1182,7 +1281,7 @@ export function registerTools(server, prisma, options = {}) {
if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Plan creation failed');
return textResult(data);
} else if (args.action === 'apply_plan') {
const resp = await fetch(`${appBaseUrl}/api/ai/batch-organize`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/batch-organize`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan: args.plan, selectedNoteIds: args.selectedNoteIds }),
@@ -1198,7 +1297,7 @@ export function registerTools(server, prisma, options = {}) {
case 'suggest_auto_labels': {
if (!appBaseUrl) throw new McpError(ErrorCode.InternalError, 'App base URL not configured for AI features');
if (args.action === 'suggest') {
const resp = await fetch(`${appBaseUrl}/api/ai/auto-labels`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/auto-labels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId: args.notebookId, language: args.language || 'en' }),
@@ -1207,7 +1306,7 @@ export function registerTools(server, prisma, options = {}) {
if (!resp.ok) throw new McpError(ErrorCode.InternalError, data.error || 'Label suggestion failed');
return textResult(data);
} else if (args.action === 'create') {
const resp = await fetch(`${appBaseUrl}/api/ai/auto-labels`, {
const resp = await fetchWithTimeout(`${appBaseUrl}/api/ai/auto-labels`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suggestions: args.suggestions, selectedLabels: args.selectedLabels }),
@@ -1292,3 +1391,6 @@ export function registerTools(server, prisma, options = {}) {
}
});
}
// Export clear caches function for testing
export { clearAuthCaches };