From ee22dcf2143e64d49f680fca65fa59654cd93593 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 23 May 2026 17:51:16 +0200 Subject: [PATCH 1/7] feat: adjust refetch settings for API queries and notification count --- src/components/notifications/NotificationCenter.tsx | 1 + src/lib/api.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/notifications/NotificationCenter.tsx b/src/components/notifications/NotificationCenter.tsx index 1dd2f8d30..3af2b218c 100644 --- a/src/components/notifications/NotificationCenter.tsx +++ b/src/components/notifications/NotificationCenter.tsx @@ -30,6 +30,7 @@ function NotificationCenter(props: Props) { ) const unreadCountQuery = api.notifications.getUnreadCount.useQuery(undefined, { enabled: !!user, + refetchOnWindowFocus: true, refetchInterval: POLLING_INTERVALS.NOTIFICATIONS, }) diff --git a/src/lib/api.tsx b/src/lib/api.tsx index 1ec06dded..6e337b3d8 100644 --- a/src/lib/api.tsx +++ b/src/lib/api.tsx @@ -19,8 +19,8 @@ export function TRPCProvider(props: PropsWithChildren) { queries: { staleTime: ms.seconds(30), gcTime: ms.minutes(5), - refetchOnWindowFocus: true, - refetchOnReconnect: true, + refetchOnWindowFocus: false, + refetchOnReconnect: false, retry: shouldRetryTRPCQuery, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }, From c7489ab3a13930fb16acc7795555fe0efe324ec4 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 23 May 2026 18:23:58 +0200 Subject: [PATCH 2/7] feat: remove monitoring components and related server utilities for system performance and database connections --- src/app/admin/components/AdminNavIcon.tsx | 2 - src/app/admin/config/routes.ts | 3 +- src/app/admin/data.ts | 6 - .../components/BundleSizeMonitor.tsx | 156 ------- .../components/DatabaseConnectionMonitor.tsx | 323 -------------- .../components/PerformanceMetrics.tsx | 158 ------- src/app/admin/monitoring/page.tsx | 25 -- src/server/api/root.ts | 2 - src/server/api/routers/system.ts | 51 --- src/server/init.ts | 24 -- src/server/utils/connection-monitor.ts | 401 ------------------ 11 files changed, 1 insertion(+), 1150 deletions(-) delete mode 100644 src/app/admin/monitoring/components/BundleSizeMonitor.tsx delete mode 100644 src/app/admin/monitoring/components/DatabaseConnectionMonitor.tsx delete mode 100644 src/app/admin/monitoring/components/PerformanceMetrics.tsx delete mode 100644 src/app/admin/monitoring/page.tsx delete mode 100644 src/server/api/routers/system.ts delete mode 100644 src/server/utils/connection-monitor.ts diff --git a/src/app/admin/components/AdminNavIcon.tsx b/src/app/admin/components/AdminNavIcon.tsx index 11940caae..495a1d282 100644 --- a/src/app/admin/components/AdminNavIcon.tsx +++ b/src/app/admin/components/AdminNavIcon.tsx @@ -17,7 +17,6 @@ import { Gavel, TrendingUp, Key, - Activity, FileKey, Award, Microchip, @@ -41,7 +40,6 @@ const getAdminNavIcon = (href: string, className: string) => { if (href.includes(ADMIN_ROUTES.REPORTS)) return if (href.includes(ADMIN_ROUTES.USER_BANS)) return if (href.includes(ADMIN_ROUTES.TRUST_LOGS)) return - if (href.includes(ADMIN_ROUTES.MONITORING)) return if (href.includes(ADMIN_ROUTES.PERMISSION_LOGS)) return if (href.includes(ADMIN_ROUTES.PERMISSIONS)) return if (href.includes(ADMIN_ROUTES.BADGES)) return diff --git a/src/app/admin/config/routes.ts b/src/app/admin/config/routes.ts index baf0853ac..82afb1d43 100644 --- a/src/app/admin/config/routes.ts +++ b/src/app/admin/config/routes.ts @@ -35,10 +35,9 @@ export const ADMIN_ROUTES = { API_ACCESS: '/admin/api-access', API_ACCESS_DEV: '/admin/api-access/developer', - // Reports & Monitoring + // Reports REPORTS: '/admin/reports', TRUST_LOGS: '/admin/trust-logs', - MONITORING: '/admin/monitoring', PERFORMANCE: '/admin/performance', ANDROID_RELEASES: '/admin/releases', ENTITLEMENTS: '/admin/entitlements', diff --git a/src/app/admin/data.ts b/src/app/admin/data.ts index 8e3f65002..7f07ab163 100644 --- a/src/app/admin/data.ts +++ b/src/app/admin/data.ts @@ -170,12 +170,6 @@ export const superAdminNavItems: AdminNavItem[] = [ exact: false, description: 'Manage roles and permissions.', }, - { - href: ADMIN_ROUTES.MONITORING, - label: 'System Monitoring', - exact: true, - description: 'Monitor system performance and metrics.', - }, { href: ADMIN_ROUTES.PERMISSION_LOGS, label: 'Permission Logs', diff --git a/src/app/admin/monitoring/components/BundleSizeMonitor.tsx b/src/app/admin/monitoring/components/BundleSizeMonitor.tsx deleted file mode 100644 index dae8bddc8..000000000 --- a/src/app/admin/monitoring/components/BundleSizeMonitor.tsx +++ /dev/null @@ -1,156 +0,0 @@ -'use client' - -import { Package, FileText, AlertTriangle } from 'lucide-react' -import { useState, useEffect } from 'react' -import { Card } from '@/components/ui' -import { cn } from '@/lib/utils' - -interface BundleInfo { - name: string - size: number - gzipSize: number - category: 'page' | 'component' | 'library' -} - -export function BundleSizeMonitor() { - const [bundles, setBundles] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - // Get bundle information from Next.js build output - const fetchBundleInfo = async () => { - try { - // TODO: In production, this would connect to the build analytics - // For now, we'll use performance.getEntriesByType to get script sizes - const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[] - - const scripts = resources - .filter((resource) => resource.name.includes('.js') || resource.name.includes('.css')) - .map((resource) => ({ - name: resource.name.split('/').pop() || 'unknown', - size: resource.transferSize || 0, - gzipSize: resource.encodedBodySize || 0, - category: resource.name.includes('_app') - ? 'page' - : resource.name.includes('node_modules') - ? 'library' - : ('component' as 'page' | 'component' | 'library'), - })) - .filter((bundle) => bundle.size > 0) - .sort((a, b) => b.size - a.size) - .slice(0, 10) // Top 10 largest bundles - - setBundles(scripts) - setLoading(false) - } catch (error) { - console.error('Failed to fetch bundle info:', error) - setLoading(false) - } - } - - fetchBundleInfo() - }, []) - - const formatBytes = (bytes: number) => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(2)} MB` - } - - const getCategoryIcon = (category: string) => { - switch (category) { - case 'page': - return - case 'library': - return - default: - return - } - } - - const getCategoryColor = (category: string) => { - switch (category) { - case 'page': - return 'text-blue-500' - case 'library': - return 'text-purple-500' - default: - return 'text-gray-500' - } - } - - const totalSize = bundles.reduce((sum, bundle) => sum + bundle.size, 0) - const totalGzipSize = bundles.reduce((sum, bundle) => sum + bundle.gzipSize, 0) - - return ( - -
-
-

Bundle Size Monitor

-

Track JavaScript and CSS bundle sizes

-
- {totalSize > 0 && ( -
-

Total Size

-

{formatBytes(totalGzipSize)}

-

gzipped

-
- )} -
- {loading ? ( -
Loading bundle information...
- ) : bundles.length === 0 ? ( -
- -

No bundle information available

-

Run a production build to see bundle sizes

-
- ) : ( -
- {bundles.map((bundle, index) => ( -
-
-
- - {getCategoryIcon(bundle.category)} - - {bundle.name} -
- {formatBytes(bundle.gzipSize)} -
-
-
-
-
100 * 1024 - ? 'bg-red-500' - : bundle.gzipSize > 50 * 1024 - ? 'bg-amber-500' - : 'bg-green-500', - )} - style={{ - width: `${(bundle.gzipSize / totalGzipSize) * 100}%`, - }} - /> -
-
- - {((bundle.gzipSize / totalGzipSize) * 100).toFixed(0)}% - -
-
- ))} -
- )} - -
-

- Bundle sizes are measured from loaded resources. For detailed analysis, run{' '} - pnpm analyze -

-
- - ) -} diff --git a/src/app/admin/monitoring/components/DatabaseConnectionMonitor.tsx b/src/app/admin/monitoring/components/DatabaseConnectionMonitor.tsx deleted file mode 100644 index df15e5b46..000000000 --- a/src/app/admin/monitoring/components/DatabaseConnectionMonitor.tsx +++ /dev/null @@ -1,323 +0,0 @@ -'use client' - -import { AlertTriangle, CheckCircle, Database, Loader2, Server, XCircle } from 'lucide-react' -import { useState } from 'react' -import { Badge, Button, Card } from '@/components/ui' -import { api } from '@/lib/api' - -export function DatabaseConnectionMonitor() { - const [autoRefresh, setAutoRefresh] = useState(true) - - const { - data: connectionStats, - isLoading: isLoadingStats, - refetch: refetchStats, - } = api.system.getConnectionStats.useQuery(undefined, { - refetchInterval: autoRefresh ? 5000 : false, - }) - - const { - data: activeConnections, - isLoading: isLoadingActive, - refetch: refetchActive, - } = api.system.getActiveConnections.useQuery(undefined, { - refetchInterval: autoRefresh ? 10000 : false, - }) - - const { - data: longRunningQueries, - isLoading: isLoadingLong, - refetch: refetchLong, - } = api.system.getLongRunningQueries.useQuery(undefined, { - refetchInterval: autoRefresh ? 10000 : false, - }) - - const { data: configValidation } = api.system.validateDatabaseConfig.useQuery() - const { data: poolConfig } = api.system.getPoolConfig.useQuery() - - const handleRefreshAll = () => { - void refetchStats() - void refetchActive() - void refetchLong() - } - - // Calculate connection health - const getConnectionHealth = () => { - if (!connectionStats) return 'unknown' - const activePercent = (connectionStats.active / connectionStats.total) * 100 - if (connectionStats.idleInTransaction > 10) return 'critical' - if (activePercent > 80) return 'warning' - return 'healthy' - } - - const health = getConnectionHealth() - - return ( -
-
-
-

Database Connections

-

- Monitor PostgreSQL connection pool and query performance -

-
-
- - -
-
- - {/* Configuration Status */} - {configValidation && ( - -
-
-

Configuration Status

- {configValidation.isValid ? ( -
- - Configuration is valid -
- ) : ( -
-
- - Configuration Issues Detected -
-
    - {configValidation.warnings.map((warning, idx) => ( -
  • {warning}
  • - ))} -
-
- )} -
- {poolConfig && ( -
-
- {poolConfig.isLocal ? 'Local Database' : 'Pool Configuration'} -
-
- {poolConfig.isLocal ? ( -
- Local Development -
- ) : ( - <> -
- pgBouncer:{' '} - - {poolConfig.hasPgBouncer ? 'Enabled' : 'Disabled'} - -
- {poolConfig.connectionLimit && ( -
- Limit:{' '} - - {poolConfig.connectionLimit} - -
- )} - - )} -
-
- )} -
-
- )} - - {/* Connection Stats Overview */} -
- -
-

Total Connections

- -
-
- {isLoadingStats ? ( - - ) : ( - (connectionStats?.total ?? 0) - )} -
-

All database connections

-
- - -
-

Active

- -
-
- {isLoadingStats ? ( - - ) : ( - (connectionStats?.active ?? 0) - )} -
-

Executing queries

-
- - -
-

Idle

- -
-
- {isLoadingStats ? ( - - ) : ( - (connectionStats?.idle ?? 0) - )} -
-

Available connections

-
- - -
-

In Transaction

- -
-
- {isLoadingStats ? ( - - ) : ( - (connectionStats?.idleInTransaction ?? 0) - )} -
-

May be stuck

-
- - -
-

Health Status

- {health === 'healthy' && } - {health === 'warning' && } - {health === 'critical' && } -
-
{health}
-

Overall connection health

-
-
- - {/* Long-Running Queries */} - {longRunningQueries && longRunningQueries.length > 0 && ( - -

- Long-Running Queries ({longRunningQueries.length}) -

-
- - - - - - - - - - - - - {longRunningQueries.map((conn) => ( - - - - - - - - - ))} - -
PIDUserApplicationStateStartedQuery
{conn.pid}{conn.username}{conn.applicationName} - {conn.state} - - {conn.queryStart ? new Date(conn.queryStart).toLocaleTimeString() : 'Unknown'} - - {conn.query ?? 'N/A'} -
-
-
- )} - - {/* Active Connections */} - {activeConnections && activeConnections.length > 0 && ( - -

Active Connections ({activeConnections.length})

-
- - - - - - - - - - - - - {activeConnections.map((conn) => ( - - - - - - - - - ))} - -
PIDUserApplicationStateWait EventQuery
{conn.pid}{conn.username}{conn.applicationName} - - {conn.state} - - {conn.waitEvent ?? 'None'} - {conn.query ?? 'N/A'} -
-
-
- )} - - {/* Empty State */} - {!isLoadingActive && - !isLoadingLong && - (!activeConnections || activeConnections.length === 0) && - (!longRunningQueries || longRunningQueries.length === 0) && ( - - -

All Clear!

-

- No active connections or long-running queries detected. -

-
- )} -
- ) -} diff --git a/src/app/admin/monitoring/components/PerformanceMetrics.tsx b/src/app/admin/monitoring/components/PerformanceMetrics.tsx deleted file mode 100644 index d77d8b915..000000000 --- a/src/app/admin/monitoring/components/PerformanceMetrics.tsx +++ /dev/null @@ -1,158 +0,0 @@ -'use client' - -import { Activity, Zap, AlertCircle, CheckCircle } from 'lucide-react' -import { useState, useEffect } from 'react' -import { Card } from '@/components/ui' -import { cn } from '@/lib/utils' - -interface Metric { - name: string - value: number - unit: string - status: 'good' | 'warning' | 'error' - description: string -} - -export function PerformanceMetrics() { - const [metrics, setMetrics] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - // Fetch real performance metrics from browser Performance API - const fetchMetrics = async () => { - try { - const navigation = performance.getEntriesByType( - 'navigation', - )[0] as PerformanceNavigationTiming - const paint = performance.getEntriesByType('paint') - - const fcp = paint.find((entry) => entry.name === 'first-contentful-paint') - - const newMetrics: Metric[] = [ - { - name: 'First Contentful Paint', - value: fcp ? fcp.startTime / 1000 : 0, - unit: 's', - status: 'good', - description: 'Time until first content is rendered', - }, - { - name: 'DOM Content Loaded', - value: navigation.domContentLoadedEventEnd / 1000, - unit: 's', - status: getStatus('DOM Content Loaded', navigation.domContentLoadedEventEnd / 1000), - description: 'Time until DOM is fully loaded', - }, - { - name: 'Page Load Time', - value: navigation.loadEventEnd / 1000, - unit: 's', - status: getStatus('Page Load Time', navigation.loadEventEnd / 1000), - description: 'Total page load time', - }, - { - name: 'Time to First Byte', - value: navigation.responseStart - navigation.requestStart, - unit: 'ms', - status: getStatus( - 'Time to First Byte', - navigation.responseStart - navigation.requestStart, - ), - description: 'Server response time', - }, - ] - - setMetrics(newMetrics) - setLoading(false) - } catch (error) { - console.error('Failed to fetch performance metrics:', error) - setLoading(false) - } - } - - fetchMetrics() - const interval = setInterval(fetchMetrics, 10000) - return () => clearInterval(interval) - }, []) - - const getStatus = (name: string, value: number): 'good' | 'warning' | 'error' => { - const thresholds: Record = { - 'First Contentful Paint': { good: 1.8, warning: 3.0 }, - 'Largest Contentful Paint': { good: 2.5, warning: 4.0 }, - 'Time to Interactive': { good: 3.8, warning: 7.3 }, - 'Total Blocking Time': { good: 150, warning: 350 }, - 'Cumulative Layout Shift': { good: 0.1, warning: 0.25 }, - 'Speed Index': { good: 3.4, warning: 5.8 }, - } - - const threshold = thresholds[name] - if (!threshold) return 'good' - - if (value <= threshold.good) return 'good' - if (value <= threshold.warning) return 'warning' - return 'error' - } - - const getStatusIcon = (status: 'good' | 'warning' | 'error') => { - switch (status) { - case 'good': - return - case 'warning': - return - case 'error': - return - } - } - - const getStatusColor = (status: 'good' | 'warning' | 'error') => { - switch (status) { - case 'good': - return 'text-green-500' - case 'warning': - return 'text-amber-500' - case 'error': - return 'text-red-500' - } - } - - return ( - -
- -
-

Core Web Vitals

-

Real user performance metrics

-
-
-
- {metrics.map((metric) => ( -
-
-

{metric.name}

- {getStatusIcon(metric.status)} -
-
- - {metric.value.toFixed(metric.unit === '' ? 2 : 1)} - - {metric.unit && {metric.unit}} -
-

{metric.description}

-
- ))} -
- - {!loading && metrics.length > 0 && ( -
-
- -

Performance Summary

-
-

- Based on real browser performance data. Metrics update every 10 seconds. -

-
- )} -
- ) -} diff --git a/src/app/admin/monitoring/page.tsx b/src/app/admin/monitoring/page.tsx deleted file mode 100644 index 740da45fc..000000000 --- a/src/app/admin/monitoring/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { type Metadata } from 'next' -import { AdminPageLayout } from '@/components/admin' -import { BundleSizeMonitor } from './components/BundleSizeMonitor' -import { DatabaseConnectionMonitor } from './components/DatabaseConnectionMonitor' -import { PerformanceMetrics } from './components/PerformanceMetrics' - -export const metadata: Metadata = { - title: 'System Monitoring - Admin Dashboard', - description: 'Monitor system performance and bundle sizes', -} - -export default function MonitoringDashboard() { - return ( - - - - - - - - ) -} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 923774dbe..a9949f3e7 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -33,7 +33,6 @@ import { rawgRouter } from './routers/rawg' import { releasesRouter } from './routers/releases' import { socialRouter } from './routers/social' import { socsRouter } from './routers/socs' -import { systemRouter } from './routers/system' import { systemsRouter } from './routers/systems' import { tgdbRouter } from './routers/tgdb' import { trustRouter } from './routers/trust' @@ -55,7 +54,6 @@ export const appRouter = createTRPCRouter({ deviceBrands: deviceBrandsRouter, socs: socsRouter, games: gamesRouter, - system: systemRouter, systems: systemsRouter, emulators: emulatorsRouter, users: usersRouter, diff --git a/src/server/api/routers/system.ts b/src/server/api/routers/system.ts deleted file mode 100644 index 85ef6a1de..000000000 --- a/src/server/api/routers/system.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { adminProcedure, createTRPCRouter } from '@/server/api/trpc' -import { ConnectionMonitor } from '@/server/utils/connection-monitor' - -/** - * System monitoring and diagnostics router - * Admin-only endpoints for database and application health monitoring - */ -export const systemRouter = createTRPCRouter({ - /** - * Get current database connection statistics - */ - getConnectionStats: adminProcedure.query(async () => { - return await ConnectionMonitor.getConnectionStats() - }), - - /** - * Get detailed information about active database connections - */ - getActiveConnections: adminProcedure.query(async () => { - return await ConnectionMonitor.getActiveConnections() - }), - - /** - * Get long-running queries (> 5 seconds) - */ - getLongRunningQueries: adminProcedure.query(async () => { - return await ConnectionMonitor.getLongRunningQueries() - }), - - /** - * Get database configuration validation - */ - validateDatabaseConfig: adminProcedure.query(() => { - return ConnectionMonitor.validateConfig() - }), - - /** - * Get connection pool configuration - */ - getPoolConfig: adminProcedure.query(() => { - return ConnectionMonitor.getPoolConfig() - }), - - /** - * Manually trigger connection stats logging - */ - logConnectionStats: adminProcedure.mutation(async () => { - await ConnectionMonitor.logConnectionStats() - return { success: true } - }), -}) diff --git a/src/server/init.ts b/src/server/init.ts index 6ea749302..a055a33af 100644 --- a/src/server/init.ts +++ b/src/server/init.ts @@ -9,7 +9,6 @@ import { logger } from '@/lib/logger' import { ms } from '@/utils/time' import { initializeNotificationService } from './notifications/init' -import { ConnectionMonitor } from './utils/connection-monitor' import { initializeSwitchGameService } from './utils/switchGameInit' import { initializeThreeDsGameService } from './utils/threeDsGameInit' @@ -177,29 +176,6 @@ export async function initializeServer(): Promise { } else { logger.warn('⚠️ Some services failed to initialize. They will be retried on next access.') } - - // Initialize connection monitoring (production only) - if (process.env.NODE_ENV === 'production') { - // Validate database configuration - const configValidation = ConnectionMonitor.validateConfig() - if (!configValidation.isValid) { - logger.warn('⚠️ Database configuration issues detected', { - warnings: configValidation.warnings, - }) - } - - // Start monitoring every 2 minutes in production - ConnectionMonitor.startMonitoring(2 * 60 * 1000) - logger.info('✅ Connection monitoring started') - } else { - // In development, just validate config once - const configValidation = ConnectionMonitor.validateConfig() - if (!configValidation.isValid) { - logger.warn('⚠️ Database configuration issues detected', { - warnings: configValidation.warnings, - }) - } - } } /** diff --git a/src/server/utils/connection-monitor.ts b/src/server/utils/connection-monitor.ts deleted file mode 100644 index cec386f79..000000000 --- a/src/server/utils/connection-monitor.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { logger } from '@/lib/logger' -import { prisma } from '@/server/db' - -interface ConnectionStats { - total: number - active: number - idle: number - idleInTransaction: number - waiting: number -} - -interface DetailedConnection { - pid: number - username: string - applicationName: string - clientAddr: string | null - state: string - queryStart: Date | null - stateChange: Date | null - waitEventType: string | null - waitEvent: string | null - query: string | null -} - -/** - * Connection monitoring utility for tracking PostgreSQL connection health - * - * Use this to: - * - Monitor active connections - * - Identify connection leaks - * - Track query performance - * - Alert on connection exhaustion - */ -export class ConnectionMonitor { - private static isMonitoring = false - private static monitoringInterval: NodeJS.Timeout | null = null - - /** - * Get current connection statistics - */ - static async getConnectionStats(): Promise { - try { - const result = await prisma.$queryRaw< - { - state: string - count: bigint - }[] - >` - SELECT - state, - COUNT(*) as count - FROM pg_stat_activity - WHERE datname = current_database() - GROUP BY state - ` - - const stats: ConnectionStats = { - total: 0, - active: 0, - idle: 0, - idleInTransaction: 0, - waiting: 0, - } - - for (const row of result) { - const count = Number(row.count) - stats.total += count - - switch (row.state) { - case 'active': - stats.active = count - break - case 'idle': - stats.idle = count - break - case 'idle in transaction': - stats.idleInTransaction = count - break - case 'idle in transaction (aborted)': - stats.idleInTransaction += count - break - default: - stats.waiting += count - } - } - - return stats - } catch (error) { - logger.error('Failed to get connection stats', { error }) - throw error - } - } - - /** - * Get detailed information about active connections - */ - static async getActiveConnections(): Promise { - try { - const result = await prisma.$queryRaw< - { - pid: number - usename: string - application_name: string - client_addr: string | null - state: string - query_start: Date | null - state_change: Date | null - wait_event_type: string | null - wait_event: string | null - query: string | null - }[] - >` - SELECT - pid, - usename, - application_name, - client_addr::text, - state, - query_start, - state_change, - wait_event_type, - wait_event, - query - FROM pg_stat_activity - WHERE datname = current_database() - AND state != 'idle' - AND pid != pg_backend_pid() - ORDER BY query_start DESC - LIMIT 50 - ` - - return result.map((row) => ({ - pid: row.pid, - username: row.usename, - applicationName: row.application_name, - clientAddr: row.client_addr, - state: row.state, - queryStart: row.query_start, - stateChange: row.state_change, - waitEventType: row.wait_event_type, - waitEvent: row.wait_event, - query: row.query, - })) - } catch (error) { - logger.error('Failed to get active connections', { error }) - throw error - } - } - - /** - * Get long-running queries (> 5 seconds) - */ - static async getLongRunningQueries(): Promise { - try { - const result = await prisma.$queryRaw< - { - pid: number - usename: string - application_name: string - client_addr: string | null - state: string - query_start: Date | null - state_change: Date | null - wait_event_type: string | null - wait_event: string | null - query: string | null - duration_seconds: number - }[] - >` - SELECT - pid, - usename, - application_name, - client_addr::text, - state, - query_start, - state_change, - wait_event_type, - wait_event, - query, - EXTRACT(EPOCH FROM (NOW() - query_start))::integer as duration_seconds - FROM pg_stat_activity - WHERE datname = current_database() - AND state = 'active' - AND query_start < NOW() - INTERVAL '5 seconds' - AND pid != pg_backend_pid() - ORDER BY query_start ASC - LIMIT 20 - ` - - const connections = result.map((row) => ({ - pid: row.pid, - username: row.usename, - applicationName: row.application_name, - clientAddr: row.client_addr, - state: row.state, - queryStart: row.query_start, - stateChange: row.state_change, - waitEventType: row.wait_event_type, - waitEvent: row.wait_event, - query: row.query, - })) - - if (connections.length > 0) { - logger.warn('Long-running queries detected', { - count: connections.length, - queries: result.map((r) => ({ - pid: r.pid, - duration: r.duration_seconds, - query: r.query?.substring(0, 100), - })), - }) - } - - return connections - } catch (error) { - logger.error('Failed to get long-running queries', { error }) - throw error - } - } - - /** - * Log current connection statistics - */ - static async logConnectionStats(): Promise { - try { - const stats = await this.getConnectionStats() - - logger.info('Database connection stats', { - total: stats.total, - active: stats.active, - idle: stats.idle, - idleInTransaction: stats.idleInTransaction, - waiting: stats.waiting, - }) - - // Alert if too many active connections - if (stats.active > 50) { - logger.warn('High active connection count', { - active: stats.active, - threshold: 50, - }) - } - - // Alert if connections stuck in transaction - if (stats.idleInTransaction > 10) { - logger.warn('High idle-in-transaction count', { - idleInTransaction: stats.idleInTransaction, - threshold: 10, - }) - } - - // Check for long-running queries - await this.getLongRunningQueries() - } catch (error) { - logger.error('Failed to log connection stats', { error }) - } - } - - /** - * Start monitoring connections at regular intervals - * - * @param intervalMs - Monitoring interval in milliseconds (default: 60000 = 1 minute) - */ - static startMonitoring(intervalMs = 60000): void { - if (this.isMonitoring) { - logger.warn('Connection monitoring already started') - return - } - - logger.info('Starting connection monitoring', { intervalMs }) - - // Log immediately - void this.logConnectionStats() - - // Then log at intervals - this.monitoringInterval = setInterval(() => { - void this.logConnectionStats() - }, intervalMs) - - this.isMonitoring = true - } - - /** - * Stop monitoring connections - */ - static stopMonitoring(): void { - if (!this.isMonitoring) { - logger.warn('Connection monitoring not running') - return - } - - if (this.monitoringInterval) { - clearInterval(this.monitoringInterval) - this.monitoringInterval = null - } - - this.isMonitoring = false - logger.info('Connection monitoring stopped') - } - - /** - * Check if database is a local development database - */ - static isLocalDatabase(): boolean { - const url = process.env.DATABASE_URL - if (!url) return false - - try { - const urlObj = new URL(url) - const hostname = urlObj.hostname.toLowerCase() - - return ( - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' - ) - } catch { - return false - } - } - - /** - * Get connection pool configuration from DATABASE_URL - */ - static getPoolConfig(): { - hasConnectionLimit: boolean - hasPgBouncer: boolean - connectionLimit?: number - isLocal: boolean - } { - const url = process.env.DATABASE_URL - if (!url) { - return { hasConnectionLimit: false, hasPgBouncer: false, isLocal: false } - } - - try { - const urlObj = new URL(url) - const connectionLimit = urlObj.searchParams.get('connection_limit') - const pgbouncer = urlObj.searchParams.get('pgbouncer') - const isLocal = this.isLocalDatabase() - - return { - hasConnectionLimit: connectionLimit !== null, - hasPgBouncer: pgbouncer === 'true', - connectionLimit: connectionLimit ? parseInt(connectionLimit, 10) : undefined, - isLocal, - } - } catch { - return { hasConnectionLimit: false, hasPgBouncer: false, isLocal: false } - } - } - - /** - * Validate connection configuration - */ - static validateConfig(): { - isValid: boolean - warnings: string[] - } { - const warnings: string[] = [] - const config = this.getPoolConfig() - - // Skip validation for local databases - if (config.isLocal) { - return { isValid: true, warnings: [] } - } - - // Production/staging validation (Supabase, remote databases) - if (!config.hasPgBouncer) { - warnings.push( - 'DATABASE_URL missing pgbouncer=true parameter. Add it to prevent prepared statement issues.', - ) - } - - // NOTE: We don't warn about missing connection_limit anymore since it's added automatically - // by getOptimizedDatabaseUrl() in src/server/db.ts - - // Only warn if connection_limit is set but too high - if (config.connectionLimit && config.connectionLimit > 5) { - warnings.push( - `connection_limit=${config.connectionLimit} is too high for serverless. Recommended: 1-2`, - ) - } - - if (!process.env.DATABASE_DIRECT_URL) { - warnings.push( - 'DATABASE_DIRECT_URL not set. Required for complex transactions and migrations.', - ) - } - - const isValid = warnings.length === 0 - - if (!isValid) { - logger.warn('Database configuration issues detected', { warnings }) - } - - return { isValid, warnings } - } -} From 295e442813b8b9bdcb44702ed1cea92fd8304cda Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 23 May 2026 18:25:31 +0200 Subject: [PATCH 3/7] feat: remove unused dynamic import for CustomFieldList component --- src/lib/dynamic-imports.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/lib/dynamic-imports.tsx b/src/lib/dynamic-imports.tsx index 19ecbe9b8..4759089c0 100644 --- a/src/lib/dynamic-imports.tsx +++ b/src/lib/dynamic-imports.tsx @@ -1,7 +1,6 @@ import dynamic from 'next/dynamic' import { LoadingSpinner } from '@/components/ui' -// Loading component for dynamic imports const LoadingFallback = () => (
@@ -36,11 +35,6 @@ export const TranslatableMarkdown = dynamic( ) // Admin components -export const CustomFieldList = dynamic( - () => import('@/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldList'), - { loading: LoadingFallback }, -) - export const RolePermissionMatrix = dynamic( () => import('@/app/admin/permissions/components/RolePermissionMatrix'), { loading: LoadingFallback }, From 09c6c1f3f42005975e07e20c144912f873b0c1ca Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 23 May 2026 18:57:47 +0200 Subject: [PATCH 4/7] feat: database connection handling with dynamic pool size configuration --- src/server/prisma-client.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/server/prisma-client.ts b/src/server/prisma-client.ts index edc82f8be..90eeae8ee 100644 --- a/src/server/prisma-client.ts +++ b/src/server/prisma-client.ts @@ -3,6 +3,7 @@ import { PrismaClient } from '@orm/client' type PrismaClientOptions = NonNullable[0]> type PrismaClientConfig = Omit +const LOCAL_DATABASE_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']) function getDatabaseUrl() { const connectionString = process.env.DATABASE_URL @@ -14,8 +15,31 @@ function getDatabaseUrl() { return connectionString } +function getPoolMax(connectionString: string): number | undefined { + try { + const url = new URL(connectionString) + const raw = url.searchParams.get('connection_limit') + if (raw) { + const parsed = Number(raw) + if (Number.isInteger(parsed) && parsed > 0) return parsed + } + + return LOCAL_DATABASE_HOSTS.has(url.hostname.toLowerCase()) ? undefined : 1 + } catch { + return 1 + } +} + export function createPrismaClient(options?: PrismaClientConfig) { - const adapter = new PrismaPg({ connectionString: getDatabaseUrl() }) + const connectionString = getDatabaseUrl() + const poolMax = getPoolMax(connectionString) + + const adapter = new PrismaPg({ + connectionString, + ...(poolMax ? { max: poolMax } : {}), + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 10_000, + }) return new PrismaClient({ ...options, From 76ba3f3364158aa72e03df4a0d72b20025510e05 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 23 May 2026 20:18:12 +0200 Subject: [PATCH 5/7] fix: reduce listings query churn --- src/app/listings/ListingsPage.tsx | 45 ++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/app/listings/ListingsPage.tsx b/src/app/listings/ListingsPage.tsx index fd6147aa8..5622121bc 100644 --- a/src/app/listings/ListingsPage.tsx +++ b/src/app/listings/ListingsPage.tsx @@ -1,5 +1,6 @@ 'use client' +import { useUser } from '@clerk/nextjs' import { Clock, GamepadIcon, CpuIcon } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' @@ -60,7 +61,11 @@ const LISTINGS_COLUMNS: ColumnDefinition[] = [ { key: 'actions', label: 'Actions', alwaysVisible: true }, ] +const LOOKUP_DATA_STALE_TIME = ms.minutes(30) +const LOOKUP_DATA_GC_TIME = ms.hours(1) + function ListingsPage() { + const { isSignedIn } = useUser() const router = useRouter() const listingsState = useListingsState() @@ -82,9 +87,11 @@ function ListingsPage() { storageKey: storageKeys.columnVisibility.listings, }) - const userQuery = api.users.me.useQuery() + const userQuery = api.users.me.useQuery(undefined, { + enabled: isSignedIn === true, + }) const userPreferencesQuery = api.userPreferences.get.useQuery(undefined, { - enabled: !!userQuery.data, + enabled: isSignedIn === true && !!userQuery.data, staleTime: ms.seconds(30), gcTime: ms.minutes(5), }) @@ -99,13 +106,37 @@ function ListingsPage() { socIds: listingsState.socIds, }) - const systemsQuery = api.systems.get.useQuery() + const systemsQuery = api.systems.get.useQuery(undefined, { + staleTime: LOOKUP_DATA_STALE_TIME, + gcTime: LOOKUP_DATA_GC_TIME, + }) // TODO: find a better alternative to hardcoding 10000 for devices (AsyncMultiselect) - const devicesQuery = api.devices.get.useQuery({ limit: 10000 }) + const devicesQuery = api.devices.get.useQuery( + { limit: 10000 }, + { + staleTime: LOOKUP_DATA_STALE_TIME, + gcTime: LOOKUP_DATA_GC_TIME, + }, + ) // TODO: find a better alternative to hardcoding 10000 for SoCs (AsyncMultiselect) - const socsQuery = api.socs.get.useQuery({ limit: 10000 }) - const emulatorsQuery = api.emulators.get.useQuery({ limit: 100 }) - const performanceScalesQuery = api.listings.performanceScales.useQuery() + const socsQuery = api.socs.get.useQuery( + { limit: 10000 }, + { + staleTime: LOOKUP_DATA_STALE_TIME, + gcTime: LOOKUP_DATA_GC_TIME, + }, + ) + const emulatorsQuery = api.emulators.get.useQuery( + { limit: 100 }, + { + staleTime: LOOKUP_DATA_STALE_TIME, + gcTime: LOOKUP_DATA_GC_TIME, + }, + ) + const performanceScalesQuery = api.listings.performanceScales.useQuery(undefined, { + staleTime: LOOKUP_DATA_STALE_TIME, + gcTime: LOOKUP_DATA_GC_TIME, + }) const filterParams: RouterInput['listings']['get'] = { page: listingsState.page, From a07300c679380377fd9a40ba08f749d97f9d8dc1 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 23 May 2026 20:18:46 +0200 Subject: [PATCH 6/7] refactor: remove query performance utility --- src/server/api/routers/listingReports.ts | 7 +- src/server/api/routers/mobile/admin.ts | 37 +- src/server/api/routers/socs.ts | 3 +- src/server/api/routers/users.ts | 27 +- src/server/services/report-stats.service.ts | 3 +- src/server/utils/query-performance.ts | 391 -------------------- 6 files changed, 32 insertions(+), 436 deletions(-) delete mode 100644 src/server/utils/query-performance.ts diff --git a/src/server/api/routers/listingReports.ts b/src/server/api/routers/listingReports.ts index 1b1526fd5..04a079e5b 100644 --- a/src/server/api/routers/listingReports.ts +++ b/src/server/api/routers/listingReports.ts @@ -17,14 +17,13 @@ import { } from '@/server/api/trpc' import { getAuthorReportCounts } from '@/server/services/report-stats.service' import { paginate } from '@/server/utils/pagination' -import { batchQueries } from '@/server/utils/query-performance' import { validateEnum, sanitizeInput, validatePagination } from '@/server/utils/security-validation' import { PERMISSIONS } from '@/utils/permission-system' import { ApprovalStatus, type Prisma, ReportStatus, TrustAction, ReportReason } from '@orm/client' export const listingReportsRouter = createTRPCRouter({ stats: permissionProcedure(PERMISSIONS.VIEW_STATISTICS).query(async ({ ctx }) => { - const [pending, underReview, resolved, dismissed] = await batchQueries([ + const [pending, underReview, resolved, dismissed] = await Promise.all([ ctx.prisma.listingReport.count({ where: { status: ReportStatus.PENDING } }), ctx.prisma.listingReport.count({ where: { status: ReportStatus.UNDER_REVIEW } }), ctx.prisma.listingReport.count({ where: { status: ReportStatus.RESOLVED } }), @@ -77,7 +76,7 @@ export const listingReportsRouter = createTRPCRouter({ const orderBy: Prisma.ListingReportOrderByWithRelationInput = {} if (sortField && sortDirection) orderBy[sortField] = sortDirection - const [reports, total] = await batchQueries([ + const [reports, total] = await Promise.all([ ctx.prisma.listingReport.findMany({ where, orderBy, @@ -303,7 +302,7 @@ export const listingReportsRouter = createTRPCRouter({ ...(status ? { status } : {}), } - const [handheldReports, pcReports, handheldCount, pcCount] = await batchQueries([ + const [handheldReports, pcReports, handheldCount, pcCount] = await Promise.all([ ctx.prisma.listingReport.findMany({ where: handheldWhere, orderBy: { createdAt: 'desc' }, diff --git a/src/server/api/routers/mobile/admin.ts b/src/server/api/routers/mobile/admin.ts index 5f7c8021d..1d9d28374 100644 --- a/src/server/api/routers/mobile/admin.ts +++ b/src/server/api/routers/mobile/admin.ts @@ -26,7 +26,6 @@ import { logAudit } from '@/server/services/audit.service' import { listingStatsCache } from '@/server/utils/cache/instances' import { paginate } from '@/server/utils/pagination' import { buildSearchFilter } from '@/server/utils/query-builders' -import { createCountQuery } from '@/server/utils/query-performance' import { canBanUser } from '@/utils/permission-system' import { ApprovalStatus, @@ -59,26 +58,16 @@ export const mobileAdminRouter = createMobileTRPCRouter({ activeUsers, bannedUsers, ] = await Promise.all([ - createCountQuery(ctx.prisma.listing, { - status: ApprovalStatus.PENDING, - }), - createCountQuery(ctx.prisma.listing, { - status: ApprovalStatus.APPROVED, - }), - createCountQuery(ctx.prisma.listing, { - status: ApprovalStatus.REJECTED, - }), - createCountQuery(ctx.prisma.game, { status: ApprovalStatus.PENDING }), - createCountQuery(ctx.prisma.game, { status: ApprovalStatus.APPROVED }), - createCountQuery(ctx.prisma.game, { status: ApprovalStatus.REJECTED }), - createCountQuery(ctx.prisma.listingReport, { - status: ReportStatus.PENDING, - }), - createCountQuery(ctx.prisma.listingReport, { - status: ReportStatus.RESOLVED, - }), - createCountQuery(ctx.prisma.user, {}), - createCountQuery(ctx.prisma.userBan, { isActive: true }), + ctx.prisma.listing.count({ where: { status: ApprovalStatus.PENDING } }), + ctx.prisma.listing.count({ where: { status: ApprovalStatus.APPROVED } }), + ctx.prisma.listing.count({ where: { status: ApprovalStatus.REJECTED } }), + ctx.prisma.game.count({ where: { status: ApprovalStatus.PENDING } }), + ctx.prisma.game.count({ where: { status: ApprovalStatus.APPROVED } }), + ctx.prisma.game.count({ where: { status: ApprovalStatus.REJECTED } }), + ctx.prisma.listingReport.count({ where: { status: ReportStatus.PENDING } }), + ctx.prisma.listingReport.count({ where: { status: ReportStatus.RESOLVED } }), + ctx.prisma.user.count(), + ctx.prisma.userBan.count({ where: { isActive: true } }), ]) return { @@ -291,7 +280,7 @@ export const mobileAdminRouter = createMobileTRPCRouter({ if (searchConditions) where.OR = searchConditions const [total, games] = await Promise.all([ - createCountQuery(ctx.prisma.game, where), + ctx.prisma.game.count({ where }), ctx.prisma.game.findMany({ where, select: { @@ -387,7 +376,7 @@ export const mobileAdminRouter = createMobileTRPCRouter({ const where: Prisma.ListingReportWhereInput = { ...(status && { status }) } const [total, reports] = await Promise.all([ - createCountQuery(ctx.prisma.listingReport, where), + ctx.prisma.listingReport.count({ where }), ctx.prisma.listingReport.findMany({ where, include: { @@ -459,7 +448,7 @@ export const mobileAdminRouter = createMobileTRPCRouter({ } const [total, bans] = await Promise.all([ - createCountQuery(ctx.prisma.userBan, where), + ctx.prisma.userBan.count({ where }), ctx.prisma.userBan.findMany({ where, include: { diff --git a/src/server/api/routers/socs.ts b/src/server/api/routers/socs.ts index e1a415249..749f840a6 100644 --- a/src/server/api/routers/socs.ts +++ b/src/server/api/routers/socs.ts @@ -15,7 +15,6 @@ import { } from '@/server/api/trpc' import { SoCsRepository } from '@/server/repositories/socs.repository' import { paginate } from '@/server/utils/pagination' -import { batchQueries } from '@/server/utils/query-performance' export const socsRouter = createTRPCRouter({ get: publicProcedure.input(GetSoCsSchema).query(async ({ ctx, input }) => { @@ -76,7 +75,7 @@ export const socsRouter = createTRPCRouter({ }), stats: viewStatisticsProcedure.query(async ({ ctx }) => { - const [withDevices, withoutDevices] = await batchQueries([ + const [withDevices, withoutDevices] = await Promise.all([ ctx.prisma.soC.count({ where: { devices: { some: {} } } }), ctx.prisma.soC.count({ where: { devices: { none: {} } } }), ]) diff --git a/src/server/api/routers/users.ts b/src/server/api/routers/users.ts index 920852e91..d781f1872 100644 --- a/src/server/api/routers/users.ts +++ b/src/server/api/routers/users.ts @@ -37,7 +37,6 @@ import { } from '@/server/services/user-profile.service' import { buildOrderBy, paginate } from '@/server/utils/pagination' import { buildSearchFilter } from '@/server/utils/query-builders' -import { createCountQuery } from '@/server/utils/query-performance' import { updateUserRole } from '@/server/utils/roleSync' import { hasPermissionInContext, @@ -713,7 +712,7 @@ export const usersRouter = createTRPCRouter({ skip, take: limit, }), - createCountQuery(ctx.prisma.user, where), + ctx.prisma.user.count({ where }), ]) return { @@ -752,17 +751,19 @@ export const usersRouter = createTRPCRouter({ superAdminCount, bannedUsersCount, ] = await Promise.all([ - createCountQuery(ctx.prisma.user, { role: Role.USER }), - createCountQuery(ctx.prisma.user, { role: Role.AUTHOR }), - createCountQuery(ctx.prisma.user, { role: Role.DEVELOPER }), - createCountQuery(ctx.prisma.user, { role: Role.MODERATOR }), - createCountQuery(ctx.prisma.user, { role: Role.ADMIN }), - createCountQuery(ctx.prisma.user, { role: Role.SUPER_ADMIN }), - createCountQuery(ctx.prisma.user, { - userBans: { - some: { - isActive: true, - OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + ctx.prisma.user.count({ where: { role: Role.USER } }), + ctx.prisma.user.count({ where: { role: Role.AUTHOR } }), + ctx.prisma.user.count({ where: { role: Role.DEVELOPER } }), + ctx.prisma.user.count({ where: { role: Role.MODERATOR } }), + ctx.prisma.user.count({ where: { role: Role.ADMIN } }), + ctx.prisma.user.count({ where: { role: Role.SUPER_ADMIN } }), + ctx.prisma.user.count({ + where: { + userBans: { + some: { + isActive: true, + OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }], + }, }, }, }), diff --git a/src/server/services/report-stats.service.ts b/src/server/services/report-stats.service.ts index 6206a366f..ff694e6f2 100644 --- a/src/server/services/report-stats.service.ts +++ b/src/server/services/report-stats.service.ts @@ -1,4 +1,3 @@ -import { batchQueries } from '@/server/utils/query-performance' import type { PrismaClient } from '@orm/client' interface AuthorReportCounts { @@ -16,7 +15,7 @@ export async function getAuthorReportCounts( userId: string, ): Promise { const [handheldReports, pcReports, handheldListingsReported, pcListingsReported] = - await batchQueries([ + await Promise.all([ prisma.listingReport.count({ where: { listing: { authorId: userId } } }), prisma.pcListingReport.count({ where: { pcListing: { authorId: userId } } }), prisma.listing.count({ where: { authorId: userId, reports: { some: {} } } }), diff --git a/src/server/utils/query-performance.ts b/src/server/utils/query-performance.ts deleted file mode 100644 index 33861ff92..000000000 --- a/src/server/utils/query-performance.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { logger } from '@/lib/logger' - -/** - * Performance monitoring for database queries - */ -export class QueryPerformanceMonitor { - private static queries: Map = new Map() - private static enabled = process.env.NODE_ENV === 'development' - - static startQuery(name: string): () => void { - if (!this.enabled) return () => {} - - const startTime = performance.now() - - return () => { - const duration = performance.now() - startTime - this.recordQuery(name, duration) - } - } - - static recordQuery(name: string, duration: number): void { - const existing = this.queries.get(name) || { - count: 0, - totalTime: 0, - minTime: Infinity, - maxTime: 0, - avgTime: 0, - } - - existing.count++ - existing.totalTime += duration - existing.minTime = Math.min(existing.minTime, duration) - existing.maxTime = Math.max(existing.maxTime, duration) - existing.avgTime = existing.totalTime / existing.count - - this.queries.set(name, existing) - - // Log slow queries - if (duration > 1000) { - logger.warn(`Slow query detected: ${name} took ${duration.toFixed(2)}ms`) - } - } - - static getMetrics(): Record { - return Object.fromEntries(this.queries) - } - - static reset(): void { - this.queries.clear() - } -} - -interface QueryMetrics { - count: number - totalTime: number - minTime: number - maxTime: number - avgTime: number -} - -/** - * Optimize select fields to reduce data transfer - */ -export function optimizeSelect( - requiredFields: (keyof T)[], - conditionalFields?: Partial>, -): Record { - const select: Record = {} - - // Always include required fields - requiredFields.forEach((field) => { - select[String(field)] = true - }) - - // Add conditional fields - if (conditionalFields) { - Object.entries(conditionalFields).forEach(([field, include]) => { - if (include) { - select[field] = true - } - }) - } - - return select -} - -/** - * Create efficient count queries that skip unnecessary joins - */ -export function createCountQuery( - model: { count: (args: { where: T }) => Promise; _name?: string }, - where: T, -): Promise { - const end = QueryPerformanceMonitor.startQuery(`count:${model._name || 'unknown'}`) - - const query = model.count({ where }) - - return query.finally(end) -} - -/** - * Batch multiple queries for better performance - */ -export async function batchQueries( - queries: [...{ [K in keyof T]: Promise }], -): Promise { - const end = QueryPerformanceMonitor.startQuery('batch:queries') - - try { - return Promise.all(queries) as Promise - } finally { - end() - } -} - -/** - * Create indexes suggestion based on query patterns - */ -export function suggestIndexes( - model: string, - whereConditions: Record, - orderBy?: Record, -): string[] { - const suggestions: string[] = [] - const fields = Object.keys(whereConditions) - - // Single field indexes for equality checks - fields.forEach((field) => { - const condition = whereConditions[field] - if (typeof condition !== 'object' || condition === null || (condition && 'in' in condition)) { - suggestions.push(`CREATE INDEX idx_${model}_${field} ON ${model}(${field});`) - } - }) - - // Composite indexes for multiple conditions - if (fields.length > 1) { - suggestions.push( - `CREATE INDEX idx_${model}_${fields.join('_')} ON ${model}(${fields.join(', ')});`, - ) - } - - // Index for sorting - if (orderBy) { - const sortFields = Object.keys(orderBy) - sortFields.forEach((field) => { - if (!fields.includes(field)) { - suggestions.push(`CREATE INDEX idx_${model}_${field} ON ${model}(${field});`) - } - }) - } - - return [...new Set(suggestions)] -} - -/** - * Optimize includes to prevent N+1 queries - */ -export function optimizeIncludes( - baseInclude: Record, - maxDepth = 3, - currentDepth = 0, -): Record { - if (currentDepth >= maxDepth) { - // Convert deep includes to select with minimal fields - const optimized: Record = {} - - Object.entries(baseInclude).forEach(([key, value]) => { - if (value === true) { - optimized[key] = { select: { id: true, name: true } } - } else if ( - typeof value === 'object' && - value !== null && - 'include' in value && - value.include - ) { - optimized[key] = { - select: { - id: true, - ...Object.keys(value.include as Record).reduce( - (acc, k) => { - acc[k] = { select: { id: true } } - return acc - }, - {} as Record, - ), - }, - } - } else { - optimized[key] = value - } - }) - - return optimized - } - - // Recursively optimize nested includes - const optimized: Record = {} - - Object.entries(baseInclude).forEach(([key, value]) => { - if (typeof value === 'object' && value !== null && 'include' in value && value.include) { - optimized[key] = { - ...value, - include: optimizeIncludes( - value.include as Record, - maxDepth, - currentDepth + 1, - ), - } - } else { - optimized[key] = value - } - }) - - return optimized -} - -/** - * Create a query cache key based on parameters - */ -export function createQueryCacheKey( - model: string, - operation: string, - params: Record, -): string { - const sortedParams = Object.keys(params) - .sort() - .reduce( - (acc, key) => { - acc[key] = params[key] - return acc - }, - {} as Record, - ) - - return `${model}:${operation}:${JSON.stringify(sortedParams)}` -} - -/** - * Analyze query complexity and warn about potential issues - */ -export function analyzeQueryComplexity( - include?: Record, - where?: Record, -): QueryComplexityResult { - let score = 0 - const warnings: string[] = [] - - // Analyze includes depth - if (include) { - const depth = getIncludeDepth(include) - score += depth * 10 - - if (depth > 3) { - warnings.push(`Deep nesting detected (${depth} levels). Consider splitting queries.`) - } - - // Count total relations - const relationCount = countRelations(include) - score += relationCount * 5 - - if (relationCount > 10) { - warnings.push( - `Many relations included (${relationCount}). This may cause performance issues.`, - ) - } - } - - // Analyze where conditions - if (where) { - const orConditions = countOrConditions(where) - score += orConditions * 15 - - if (orConditions > 5) { - warnings.push(`Many OR conditions (${orConditions}). Consider using indexed fields.`) - } - } - - return { - score, - complexity: score < 50 ? 'low' : score < 100 ? 'medium' : 'high', - warnings, - } -} - -function getIncludeDepth(include: Record, currentDepth = 1): number { - let maxDepth = currentDepth - - Object.values(include).forEach((value) => { - if (typeof value === 'object' && value !== null && 'include' in value && value.include) { - const depth = getIncludeDepth(value.include as Record, currentDepth + 1) - maxDepth = Math.max(maxDepth, depth) - } - }) - - return maxDepth -} - -function countRelations(include: Record): number { - let count = Object.keys(include).length - - Object.values(include).forEach((value) => { - if (typeof value === 'object' && value !== null && 'include' in value && value.include) { - count += countRelations(value.include as Record) - } - }) - - return count -} - -function countOrConditions(where: Record): number { - let count = 0 - - if (where.OR && Array.isArray(where.OR)) count += where.OR.length - - Object.values(where).forEach((value) => { - if (typeof value === 'object' && value !== null) { - count += countOrConditions(value as Record) - } - }) - - return count -} - -interface QueryComplexityResult { - score: number - complexity: 'low' | 'medium' | 'high' - warnings: string[] -} - -/** - * Connection pool optimization settings - */ -export function getOptimizedPoolSettings() { - return { - // Connection limit for serverless (1 connection per instance) - connection_limit: 1, - - // Connection timeout in seconds (how long to wait for initial connection) - connect_timeout: 10, - } -} - -/** - * Generate optimized DATABASE_URL with connection pool parameters - * - * Automatically adds connection_limit=1 for remote databases to prevent - * connection exhaustion in serverless environments. - * - * IMPORTANT: Does NOT add pgbouncer=true - that must be in the environment variable - * to avoid breaking interactive transactions. - */ -export function getOptimizedDatabaseUrl(baseUrl?: string): string { - const url = baseUrl || process.env.DATABASE_URL - if (!url) { - throw new Error('DATABASE_URL is required') - } - - try { - const urlObj = new URL(url) - const hostname = urlObj.hostname.toLowerCase() - - // Skip optimization for local databases - const isLocal = - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' - - if (isLocal) return url - - // Get optimized settings - const settings = getOptimizedPoolSettings() - - // Add connection_limit if not already set - if (!urlObj.searchParams.has('connection_limit')) { - urlObj.searchParams.set('connection_limit', settings.connection_limit.toString()) - logger.info('Added connection_limit=1 to DATABASE_URL for optimal serverless performance') - } - - // Add connect_timeout if not already set - if (!urlObj.searchParams.has('connect_timeout')) { - urlObj.searchParams.set('connect_timeout', settings.connect_timeout.toString()) - } - - return urlObj.toString() - } catch (error) { - // If URL parsing fails, return as-is - logger.error('Failed to parse DATABASE_URL', { error }) - return url - } -} From 8c7ba377db0b2074377a0a860f3c1e186a8dd8a0 Mon Sep 17 00:00:00 2001 From: Producdevity Date: Sat, 23 May 2026 21:11:44 +0200 Subject: [PATCH 7/7] feat: add refetchOnWindowFocus to notifications query --- src/components/notifications/NotificationCenter.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/notifications/NotificationCenter.tsx b/src/components/notifications/NotificationCenter.tsx index 3af2b218c..5304169a3 100644 --- a/src/components/notifications/NotificationCenter.tsx +++ b/src/components/notifications/NotificationCenter.tsx @@ -26,7 +26,11 @@ function NotificationCenter(props: Props) { const notificationsQuery = api.notifications.get.useQuery( { limit: 10, offset: 0 }, - { enabled: !!user, refetchInterval: POLLING_INTERVALS.NOTIFICATIONS }, + { + enabled: !!user, + refetchOnWindowFocus: true, + refetchInterval: POLLING_INTERVALS.NOTIFICATIONS, + }, ) const unreadCountQuery = api.notifications.getUnreadCount.useQuery(undefined, { enabled: !!user,