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' }
+ });
+ }
+};