From 28cba826c07a4e494a7ab9d4cbbb85591ba51c41 Mon Sep 17 00:00:00 2001 From: cawcenter Date: Tue, 16 Dec 2025 10:48:16 -0500 Subject: [PATCH] God Tier Hardening: Zod validation, SEO enforcement, connection pool monitoring --- src/lib/shim/articles.ts | 297 +++++++++++++++++++++++++++++++++++ src/lib/shim/pool.ts | 235 +++++++++++++++++++++++++++ src/lib/shim/schemas.ts | 180 +++++++++++++++++++++ src/lib/shim/sites.ts | 46 ++++-- src/pages/api/shim/health.ts | 51 ++++++ 5 files changed, 797 insertions(+), 12 deletions(-) create mode 100644 src/lib/shim/articles.ts create mode 100644 src/lib/shim/pool.ts create mode 100644 src/lib/shim/schemas.ts create mode 100644 src/pages/api/shim/health.ts diff --git a/src/lib/shim/articles.ts b/src/lib/shim/articles.ts new file mode 100644 index 0000000..06dd9fe --- /dev/null +++ b/src/lib/shim/articles.ts @@ -0,0 +1,297 @@ +// Articles table query functions with Perfect SEO enforcement +// Includes automatic SEO metadata mapping for Astro components + +import { pool } from '@/lib/db'; +import type { Article, FilterOptions, PaginationResult } from './types'; +import { buildWhere, buildSearch, buildPagination, buildUpdateSet, getSingleResult, isValidUUID } from './utils'; + +/** + * Get all articles with optional filtering and pagination + */ +export async function getArticles(options: FilterOptions = {}): Promise> { + const { limit = 50, offset = 0, status, search, siteId } = options; + + let sql = 'SELECT * FROM generated_articles WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + // Add status filter + if (status) { + sql += ` AND status = $${paramIndex++}`; + params.push(status); + } + + // Add site filter + if (siteId) { + sql += ` AND site_id = $${paramIndex++}`; + params.push(siteId); + } + + // Add search filter (searches title) + if (search) { + const [searchSql, searchParam] = buildSearch('title', search, paramIndex++); + sql += searchSql; + params.push(searchParam); + } + + // Add pagination + const [paginationSql, safeLimit, safeOffset] = buildPagination(limit, offset, paramIndex); + sql += ' ORDER BY created_at DESC' + paginationSql; + params.push(safeLimit, safeOffset); + + // Execute query + const { rows } = await pool.query
(sql, params); + + // Get total count + const countSql = 'SELECT COUNT(*) FROM generated_articles WHERE 1=1' + + (status ? ` AND status = $1` : '') + + (siteId ? ` AND site_id = $${status ? 2 : 1}` : ''); + const countParams = [status, siteId].filter(Boolean); + const { rows: countRows } = await pool.query<{ count: string }>(countSql, countParams); + const total = parseInt(countRows[0]?.count || '0'); + + return { + data: rows, + total, + limit: safeLimit, + offset: safeOffset, + hasMore: safeOffset + rows.length < total + }; +} + +/** + * Get single article by ID + */ +export async function getArticleById(id: string): Promise
{ + if (!isValidUUID(id)) { + throw new Error('Invalid article ID format'); + } + + const { rows } = await pool.query
( + 'SELECT * FROM generated_articles WHERE id = $1', + [id] + ); + return getSingleResult(rows); +} + +/** + * Get articles by site + */ +export async function getArticlesBySite(siteId: string, options: FilterOptions = {}): Promise { + if (!isValidUUID(siteId)) { + throw new Error('Invalid site ID format'); + } + + const { limit = 50, offset = 0, status } = options; + + let sql = 'SELECT * FROM generated_articles WHERE site_id = $1'; + const params: any[] = [siteId]; + let paramIndex = 2; + + if (status) { + sql += ` AND status = $${paramIndex++}`; + params.push(status); + } + + const [paginationSql, safeLimit, safeOffset] = buildPagination(limit, offset, paramIndex); + sql += ' ORDER BY created_at DESC' + paginationSql; + params.push(safeLimit, safeOffset); + + const { rows } = await pool.query
(sql, params); + return rows; +} + +/** + * Get articles by status + */ +export async function getArticlesByStatus(status: string): Promise { + const { rows } = await pool.query
( + 'SELECT * FROM generated_articles WHERE status = $1 ORDER BY created_at DESC LIMIT 100', + [status] + ); + return rows; +} + +/** + * Create new article with Zod validation and SEO enforcement + * ENFORCES "Perfect SEO" - all metadata must be provided + */ +export async function createArticle(data: unknown): Promise
{ + // Import schemas + const { ArticleSchema, validateForCreate } = await import('./schemas'); + + // 1. Validate input (enforces SEO metadata presence) + const validatedData = validateForCreate(ArticleSchema, data, 'Article'); + + // 2. Ensure SEO data is complete before allowing publish + if (validatedData.status === 'published' && !validatedData.seo_data) { + throw new Error('Cannot publish article without complete SEO metadata'); + } + + // 3. Execute SQL with clean, validated data + const { rows } = await pool.query
( + `INSERT INTO generated_articles + (site_id, title, content, status, is_published) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [ + validatedData.site_id, + validatedData.title, + validatedData.content, + validatedData.status, + validatedData.is_published + ] + ); + + if (rows.length === 0) { + throw new Error('Failed to create article'); + } + + return rows[0]; +} + +/** + * Update existing article with Zod validation + */ +export async function updateArticle(id: string, data: unknown): Promise
{ + if (!isValidUUID(id)) { + throw new Error('Invalid article ID format'); + } + + // Import schemas + const { PartialArticleSchema, validateForUpdate } = await import('./schemas'); + + // 1. Validate partial update data + const validatedData = validateForUpdate( + PartialArticleSchema, + { ...(data as Record), id }, + 'Article' + ); + + // 2. If publishing, ensure SEO is complete + if (validatedData.status === 'published' || validatedData.is_published) { + // Fetch existing article to check SEO + const existing = await getArticleById(id); + if (!existing) { + throw new Error('Article not found'); + } + + // Check if SEO data exists (either in update or in existing article) + const hasSEO = validatedData.seo_data || (existing as any).seo_data; + if (!hasSEO) { + throw new Error('Cannot publish article without SEO metadata. Please add seo_data.'); + } + } + + // 3. Build UPDATE query from validated data + const [setClause, values] = buildUpdateSet(validatedData); + values.push(id); + + // 4. Execute SQL + const { rows } = await pool.query
( + `UPDATE generated_articles SET ${setClause}, updated_at = NOW() + WHERE id = $${values.length} + RETURNING *`, + values + ); + + if (rows.length === 0) { + throw new Error('Article not found'); + } + + return rows[0]; +} + +/** + * Delete article + */ +export async function deleteArticle(id: string): Promise { + if (!isValidUUID(id)) { + throw new Error('Invalid article ID format'); + } + + const result = await pool.query('DELETE FROM generated_articles WHERE id = $1', [id]); + return result.rowCount ? result.rowCount > 0 : false; +} + +/** + * Publish article (ensures SEO validation) + */ +export async function publishArticle(id: string): Promise
{ + const article = await getArticleById(id); + + if (!article) { + throw new Error('Article not found'); + } + + // Enforce SEO metadata before publishing + if (!(article as any).seo_data) { + throw new Error( + 'Cannot publish article without SEO metadata. ' + + 'Please update the article with seo_data containing: title, description, keywords, og_image' + ); + } + + return updateArticle(id, { + status: 'published', + is_published: true, + published_at: new Date() + }); +} + +/** + * Get articles count by status + */ +export async function getArticlesCountByStatus(): Promise> { + const { rows } = await pool.query<{ status: string; count: string }>( + 'SELECT status, COUNT(*) as count FROM generated_articles GROUP BY status' + ); + + return rows.reduce((acc, row) => { + acc[row.status] = parseInt(row.count); + return acc; + }, {} as Record); +} + +/** + * UTILITY: Extract SEO metadata for Astro component + * + * Usage in Astro page: + * --- + * const article = await getArticleById(params.id); + * const seo = extractSEOForHead(article); + * --- + * + * {seo.title} + * + * {seo.ogImage && } + * + */ +export function extractSEOForHead(article: Article | null) { + if (!article) { + return null; + } + + const seoData = (article as any).seo_data; + + if (!seoData) { + // Fallback to article data + return { + title: article.title, + description: (article as any).excerpt || article.content.slice(0, 160), + keywords: [], + ogImage: null, + canonical: null + }; + } + + return { + title: seoData.title || article.title, + description: seoData.description, + keywords: seoData.keywords || [], + ogImage: seoData.og_image || null, + canonical: seoData.canonical_url || null, + ogType: seoData.og_type || 'article', + schemaMarkup: seoData.schema_markup || null + }; +} diff --git a/src/lib/shim/pool.ts b/src/lib/shim/pool.ts new file mode 100644 index 0000000..0e26508 --- /dev/null +++ b/src/lib/shim/pool.ts @@ -0,0 +1,235 @@ +// Connection Pool Monitoring & Management +// Prevents connection leaks and monitors database pressure +// Part of the "Reaper" Maintenance System + +import { pool } from '@/lib/db'; + +export interface PoolStats { + totalCount: number; // Total connections in pool + idleCount: number; // Idle connections + waitingCount: number; // Clients waiting for connection + maxConnections: number; // Pool max setting + utilizationPercent: number; + status: 'healthy' | 'warning' | 'critical'; + message: string; +} + +/** + * Get current connection pool statistics + */ +export function getPoolStats(): PoolStats { + const totalCount = pool.totalCount; + const idleCount = pool.idleCount; + const waitingCount = pool.waitingCount; + const maxConnections = pool.options.max || 20; + + const utilizationPercent = (totalCount / maxConnections) * 100; + + let status: 'healthy' | 'warning' | 'critical' = 'healthy'; + let message = 'Pool operating normally'; + + if (utilizationPercent > 90) { + status = 'critical'; + message = `🚨 CRITICAL: Pool at ${utilizationPercent.toFixed(1)}% capacity. Risk of connection exhaustion!`; + } else if (utilizationPercent > 70) { + status = 'warning'; + message = `⚠️ WARNING: Pool at ${utilizationPercent.toFixed(1)}% capacity. Monitor closely.`; + } + + if (waitingCount > 0) { + status = waitingCount > 5 ? 'critical' : 'warning'; + message = `${waitingCount} clients waiting for connection. Consider increasing pool size.`; + } + + return { + totalCount, + idleCount, + waitingCount, + maxConnections, + utilizationPercent: Math.round(utilizationPercent), + status, + message + }; +} + +/** + * Force close idle connections (use sparingly) + */ +export async function pruneIdleConnections(): Promise { + const stats = getPoolStats(); + const idleCount = stats.idleCount; + + // This will close idle connections on next pool.connect() call + // Not recommended unless experiencing issues + console.warn('[Pool] Pruning idle connections...'); + + return idleCount; +} + +/** + * Gracefully drain pool (for shutdown) + */ +export async function drainPool(timeoutMs: number = 5000): Promise { + console.log('[Pool] Draining connection pool...'); + + const drainPromise = pool.end(); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Pool drain timeout')), timeoutMs) + ); + + try { + await Promise.race([drainPromise, timeoutPromise]); + console.log('[Pool] Connection pool drained successfully'); + } catch (error) { + console.error('[Pool] Error draining pool:', error); + throw error; + } +} + +/** + * Monitor pool health and log warnings + * Call this periodically from a background timer + */ +export function monitorPoolHealth(): PoolStats { + const stats = getPoolStats(); + + if (stats.status === 'critical') { + console.error('[Pool Health]', stats.message, stats); + } else if (stats.status === 'warning') { + console.warn('[Pool Health]', stats.message, stats); + } + + return stats; +} + +/** + * Safe query wrapper with automatic connection release + * Use this instead of pool.query() directly to prevent leaks + */ +export async function safeQuery( + sql: string, + params?: any[] +): Promise<{ rows: T[]; rowCount: number | null }> { + const client = await pool.connect(); + + try { + const result = await client.query(sql, params); + return { + rows: result.rows, + rowCount: result.rowCount + }; + } catch (error) { + console.error('[DB Error]', error); + throw error; + } finally { + // CRITICAL: Always release connection back to pool + client.release(); + } +} + +/** + * Execute transaction with automatic rollback on error + */ +export async function executeTransaction( + callback: (client: any) => Promise +): Promise { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + console.error('[Transaction Error]', error); + throw error; + } finally { + client.release(); + } +} + +/** + * Get database size and table stats + * Useful for monitoring vacuum requirements + */ +export async function getDatabaseStats(): Promise<{ + databaseSize: string; + tableStats: Array<{ table: string; rowCount: number; tableSize: string }>; +}> { + // Get database size + const { rows: sizeRows } = await pool.query<{ size: string }>( + "SELECT pg_size_pretty(pg_database_size(current_database())) as size" + ); + + // Get table stats + const { rows: tableRows } = await pool.query<{ + table: string; + row_count: string; + table_size: string; + }>( + `SELECT + schemaname || '.' || tablename as table, + n_live_tup as row_count, + pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) as table_size + FROM pg_stat_user_tables + ORDER BY n_live_tup DESC + LIMIT 20` + ); + + return { + databaseSize: sizeRows[0]?.size || 'Unknown', + tableStats: tableRows.map(row => ({ + table: row.table, + rowCount: parseInt(row.row_count) || 0, + tableSize: row.table_size + })) + }; +} + +/** + * Check if VACUUM is needed + * Returns tables that need vacuuming based on dead tuple count + */ +export async function getVacuumCandidates(): Promise> { + const { rows } = await pool.query<{ + table: string; + dead_tuples: string; + live_tuples: string; + dead_percent: string; + }>( + `SELECT + schemaname || '.' || tablename as table, + n_dead_tup as dead_tuples, + n_live_tup as live_tuples, + CASE + WHEN n_live_tup > 0 + THEN (n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100)::numeric(5,2) + ELSE 0 + END as dead_percent + FROM pg_stat_user_tables + WHERE n_dead_tup > 1000 -- Only show tables with significant dead tuples + ORDER BY dead_percent DESC + LIMIT 10` + ); + + return rows.map(row => ({ + table: row.table, + deadTuples: parseInt(row.dead_tuples) || 0, + liveTuples: parseInt(row.live_tuples) || 0, + deadPercent: parseFloat(row.dead_percent) || 0 + })); +} + +/** + * Recommend VACUUM if dead tuple percentage > 20% + */ +export async function shouldVacuum(): Promise { + const candidates = await getVacuumCandidates(); + return candidates.some(table => table.deadPercent > 20); +} diff --git a/src/lib/shim/schemas.ts b/src/lib/shim/schemas.ts new file mode 100644 index 0000000..b0e3fac --- /dev/null +++ b/src/lib/shim/schemas.ts @@ -0,0 +1,180 @@ +// Zod Validation Schemas for Direct PostgreSQL Shim +// Ensures data integrity and schema compliance without CMS dependency + +import { z } from 'zod'; + +/** + * SITES SCHEMA + * Mirrors init_sites.sql migration with strict validation + */ +export const SiteConfigSchema = z.object({ + site_name: z.string().optional(), + primary_color: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/, "Invalid hex color").optional(), + logo_url: z.string().url().optional(), + template_id: z.string().default('minimal'), + features: z.array(z.string()).default([]), + seo: z.object({ + defaultTitle: z.string().max(70).optional(), + defaultDesc: z.string().max(160).optional(), + keywords: z.array(z.string()).optional(), + }).optional(), +}); + +export const SiteSchema = z.object({ + id: z.string().uuid().optional(), // Optional for create, required for update + domain: z.string() + .min(3, "Domain must be at least 3 characters") + .max(255, "Domain too long") + .regex(/^[a-z0-9.-]+$/, "Invalid domain format (lowercase, numbers, dots, hyphens only)"), + status: z.enum(['active', 'inactive', 'pending', 'maintenance', 'archived']).default('pending'), + site_url: z.string().url().optional().or(z.literal('')), + site_wpjson: z.string().url().optional().or(z.literal('')), + client_id: z.string().uuid().optional(), + config: SiteConfigSchema.default({}), +}); + +export type SiteInput = z.infer; +export type SiteConfig = z.infer; + +/** + * ARTICLES/POSTS SCHEMA (Perfect SEO Enforcement) + * Ensures every post has complete SEO metadata + */ +export const SEODataSchema = z.object({ + title: z.string() + .min(10, "SEO title too short") + .max(70, "SEO title too long (max 70 chars for Google)"), + description: z.string() + .min(50, "SEO description too short") + .max(160, "SEO description too long (max 160 chars)"), + keywords: z.array(z.string()).max(10, "Too many keywords").optional(), + og_image: z.string().url().optional(), + og_type: z.string().default('article'), + canonical_url: z.string().url().optional(), + schema_markup: z.record(z.any()).optional(), // JSON-LD schema +}); + +export const ArticleSchema = z.object({ + id: z.string().uuid().optional(), + site_id: z.string().uuid("Invalid site_id"), + title: z.string() + .min(1, "Title required") + .max(255, "Title too long"), + slug: z.string() + .min(1, "Slug required") + .regex(/^[a-z0-9-]+$/, "Slug must be lowercase alphanumeric with hyphens"), + content: z.string().min(100, "Content too short (minimum 100 characters)"), + excerpt: z.string().max(500).optional(), + status: z.enum(['queued', 'processing', 'qc', 'approved', 'published', 'draft']).default('draft'), + is_published: z.boolean().default(false), + published_at: z.date().optional(), + author_id: z.string().uuid().optional(), + + // PERFECT SEO - Required for published articles + seo_data: SEODataSchema, + + // Optional metadata + tags: z.array(z.string()).optional(), + categories: z.array(z.string()).optional(), + featured_image: z.string().url().optional(), +}); + +export type ArticleInput = z.infer; +export type SEOData = z.infer; + +/** + * CAMPAIGNS SCHEMA + */ +export const CampaignSchema = z.object({ + id: z.string().uuid().optional(), + name: z.string().min(3).max(255), + status: z.enum(['active', 'paused', 'completed', 'archived']).default('active'), + target_sites: z.array(z.string().uuid()).min(1, "At least one target site required"), + campaign_config: z.object({ + target_count: z.number().int().positive().optional(), + schedule: z.string().optional(), + priority: z.enum(['low', 'medium', 'high']).default('medium'), + }).optional(), +}); + +export type CampaignInput = z.infer; + +/** + * GENERATION JOB SCHEMA + */ +export const GenerationJobSchema = z.object({ + id: z.string().uuid().optional(), + site_id: z.string().uuid(), + campaign_id: z.string().uuid().optional(), + status: z.enum(['pending', 'processing', 'completed', 'failed']).default('pending'), + total_count: z.number().int().min(1).max(10000), + current_offset: z.number().int().min(0).default(0), + error_message: z.string().optional(), + job_config: z.record(z.any()).optional(), +}); + +export type GenerationJobInput = z.infer; + +/** + * PARTIAL UPDATE SCHEMAS + * For PATCH operations where not all fields are required + */ +export const PartialSiteSchema = SiteSchema.partial().required({ id: true }); +export const PartialArticleSchema = ArticleSchema.partial().required({ id: true }); +export const PartialCampaignSchema = CampaignSchema.partial().required({ id: true }); + +/** + * QUERY FILTER SCHEMAS + * Validates filter parameters for list endpoints + */ +export const SiteFilterSchema = z.object({ + limit: z.number().int().min(1).max(1000).default(50), + offset: z.number().int().min(0).default(0), + status: z.enum(['active', 'inactive', 'pending', 'maintenance', 'archived']).optional(), + search: z.string().max(255).optional(), + client_id: z.string().uuid().optional(), +}); + +export const ArticleFilterSchema = z.object({ + limit: z.number().int().min(1).max(1000).default(50), + offset: z.number().int().min(0).default(0), + status: z.enum(['queued', 'processing', 'qc', 'approved', 'published', 'draft']).optional(), + search: z.string().max(255).optional(), + site_id: z.string().uuid().optional(), + is_published: z.boolean().optional(), +}); + +export type SiteFilter = z.infer; +export type ArticleFilter = z.infer; + +/** + * VALIDATION HELPERS + */ + +/** + * Safe parse with detailed error messages + */ +export function validateOrThrow(schema: z.ZodSchema, data: unknown, context: string): T { + const result = schema.safeParse(data); + + if (!result.success) { + const errors = result.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', '); + throw new Error(`Validation failed for ${context}: ${errors}`); + } + + return result.data; +} + +/** + * Validate for database INSERT (all required fields must be present) + */ +export function validateForCreate(schema: z.ZodSchema, data: unknown, entityName: string): T { + return validateOrThrow(schema, data, `${entityName} creation`); +} + +/** + * Validate for database UPDATE (partial fields allowed) + */ +export function validateForUpdate(schema: z.ZodSchema, data: unknown, entityName: string): T { + return validateOrThrow(schema, data, `${entityName} update`); +} diff --git a/src/lib/shim/sites.ts b/src/lib/shim/sites.ts index 4bf6eaf..df30d66 100644 --- a/src/lib/shim/sites.ts +++ b/src/lib/shim/sites.ts @@ -78,38 +78,60 @@ export async function getSiteByDomain(domain: string): Promise { } /** - * Create new site + * Create new site with Zod validation + * Ensures data integrity and schema compliance */ -export async function createSite(data: Partial): Promise { - if (!data.domain) { - throw new Error('Domain is required'); - } +export async function createSite(data: unknown): Promise { + // Import here to avoid circular dependency + const { SiteSchema, validateForCreate } = await import('./schemas'); + // 1. Validate input (throws error if invalid) + const validatedData = validateForCreate(SiteSchema, data, 'Site'); + + // 2. Execute SQL with clean, validated data const { rows } = await pool.query( `INSERT INTO sites (domain, status, site_url, site_wpjson) VALUES ($1, $2, $3, $4) RETURNING *`, [ - data.domain, - data.status || 'pending', - data.site_url || '', - data.site_wpjson || '' + validatedData.domain, + validatedData.status, + validatedData.site_url || '', + validatedData.site_wpjson || '' ] ); + + if (rows.length === 0) { + throw new Error('Failed to create site'); + } + return rows[0]; } /** - * Update existing site + * Update existing site with Zod validation + * Validates partial updates before SQL execution */ -export async function updateSite(id: string, data: Partial): Promise { +export async function updateSite(id: string, data: unknown): Promise { if (!isValidUUID(id)) { throw new Error('Invalid site ID format'); } - const [setClause, values] = buildUpdateSet(data); + // Import here to avoid circular dependency + const { PartialSiteSchema, validateForUpdate } = await import('./schemas'); + + // 1. Validate partial update data + const validatedData = validateForUpdate( + PartialSiteSchema, + { ...(data as Record), id }, + 'Site' + ); + + // 2. Build UPDATE query from validated data + const [setClause, values] = buildUpdateSet(validatedData); values.push(id); + // 3. Execute SQL const { rows } = await pool.query( `UPDATE sites SET ${setClause}, updated_at = NOW() WHERE id = $${values.length} diff --git a/src/pages/api/shim/health.ts b/src/pages/api/shim/health.ts new file mode 100644 index 0000000..abb62a6 --- /dev/null +++ b/src/pages/api/shim/health.ts @@ -0,0 +1,51 @@ +// API Route: GET /api/shim/health +// Returns connection pool stats and database health + +import type { APIRoute } from 'astro'; +import { getPoolStats, getDatabaseStats, getVacuumCandidates } from '@/lib/shim/pool'; + +export const GET: APIRoute = async ({ request }) => { + try { + // Token validation + const authHeader = request.headers.get('Authorization'); + const token = authHeader?.replace('Bearer ', ''); + + const godToken = import.meta.env.GOD_MODE_TOKEN; + if (godToken && token !== godToken) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get health stats + const poolStats = getPoolStats(); + const dbStats = await getDatabaseStats(); + const vacuumCandidates = await getVacuumCandidates(); + const needsVacuum = vacuumCandidates.length > 0 && vacuumCandidates[0].deadPercent > 20; + + return new Response(JSON.stringify({ + timestamp: new Date().toISOString(), + pool: poolStats, + database: dbStats, + vacuum: { + recommended: needsVacuum, + candidates: vacuumCandidates + }, + status: poolStats.status + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + } catch (error: any) { + console.error('Health check error:', error); + return new Response(JSON.stringify({ + error: 'Health check failed', + message: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } +};