God Tier Hardening: Zod validation, SEO enforcement, connection pool monitoring

This commit is contained in:
cawcenter
2025-12-16 10:48:16 -05:00
parent 7afd26e999
commit 28cba826c0
5 changed files with 797 additions and 12 deletions

297
src/lib/shim/articles.ts Normal file
View File

@@ -0,0 +1,297 @@
// Articles table query functions with Perfect SEO enforcement
// Includes automatic SEO metadata mapping for Astro <head> 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<PaginationResult<Article>> {
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<Article>(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<Article | null> {
if (!isValidUUID(id)) {
throw new Error('Invalid article ID format');
}
const { rows } = await pool.query<Article>(
'SELECT * FROM generated_articles WHERE id = $1',
[id]
);
return getSingleResult(rows);
}
/**
* Get articles by site
*/
export async function getArticlesBySite(siteId: string, options: FilterOptions = {}): Promise<Article[]> {
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<Article>(sql, params);
return rows;
}
/**
* Get articles by status
*/
export async function getArticlesByStatus(status: string): Promise<Article[]> {
const { rows } = await pool.query<Article>(
'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<Article> {
// 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<Article>(
`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<Article> {
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<string, any>), 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<Article>(
`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<boolean> {
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<Article> {
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<Record<string, number>> {
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<string, number>);
}
/**
* UTILITY: Extract SEO metadata for Astro <head> component
*
* Usage in Astro page:
* ---
* const article = await getArticleById(params.id);
* const seo = extractSEOForHead(article);
* ---
* <head>
* <title>{seo.title}</title>
* <meta name="description" content={seo.description} />
* {seo.ogImage && <meta property="og:image" content={seo.ogImage} />}
* </head>
*/
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
};
}

235
src/lib/shim/pool.ts Normal file
View File

@@ -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<number> {
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<void> {
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<T = any>(
sql: string,
params?: any[]
): Promise<{ rows: T[]; rowCount: number | null }> {
const client = await pool.connect();
try {
const result = await client.query<T>(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<T>(
callback: (client: any) => Promise<T>
): Promise<T> {
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<Array<{
table: string;
deadTuples: number;
liveTuples: number;
deadPercent: number;
}>> {
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<boolean> {
const candidates = await getVacuumCandidates();
return candidates.some(table => table.deadPercent > 20);
}

180
src/lib/shim/schemas.ts Normal file
View File

@@ -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<typeof SiteSchema>;
export type SiteConfig = z.infer<typeof SiteConfigSchema>;
/**
* 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<typeof ArticleSchema>;
export type SEOData = z.infer<typeof SEODataSchema>;
/**
* 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<typeof CampaignSchema>;
/**
* 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<typeof GenerationJobSchema>;
/**
* 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<typeof SiteFilterSchema>;
export type ArticleFilter = z.infer<typeof ArticleFilterSchema>;
/**
* VALIDATION HELPERS
*/
/**
* Safe parse with detailed error messages
*/
export function validateOrThrow<T>(schema: z.ZodSchema<T>, 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<T>(schema: z.ZodSchema<T>, data: unknown, entityName: string): T {
return validateOrThrow(schema, data, `${entityName} creation`);
}
/**
* Validate for database UPDATE (partial fields allowed)
*/
export function validateForUpdate<T>(schema: z.ZodSchema<T>, data: unknown, entityName: string): T {
return validateOrThrow(schema, data, `${entityName} update`);
}

View File

@@ -78,38 +78,60 @@ export async function getSiteByDomain(domain: string): Promise<Site | null> {
} }
/** /**
* Create new site * Create new site with Zod validation
* Ensures data integrity and schema compliance
*/ */
export async function createSite(data: Partial<Site>): Promise<Site> { export async function createSite(data: unknown): Promise<Site> {
if (!data.domain) { // Import here to avoid circular dependency
throw new Error('Domain is required'); 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<Site>( const { rows } = await pool.query<Site>(
`INSERT INTO sites (domain, status, site_url, site_wpjson) `INSERT INTO sites (domain, status, site_url, site_wpjson)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING *`, RETURNING *`,
[ [
data.domain, validatedData.domain,
data.status || 'pending', validatedData.status,
data.site_url || '', validatedData.site_url || '',
data.site_wpjson || '' validatedData.site_wpjson || ''
] ]
); );
if (rows.length === 0) {
throw new Error('Failed to create site');
}
return rows[0]; 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<Site>): Promise<Site> { export async function updateSite(id: string, data: unknown): Promise<Site> {
if (!isValidUUID(id)) { if (!isValidUUID(id)) {
throw new Error('Invalid site ID format'); 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<string, any>), id },
'Site'
);
// 2. Build UPDATE query from validated data
const [setClause, values] = buildUpdateSet(validatedData);
values.push(id); values.push(id);
// 3. Execute SQL
const { rows } = await pool.query<Site>( const { rows } = await pool.query<Site>(
`UPDATE sites SET ${setClause}, updated_at = NOW() `UPDATE sites SET ${setClause}, updated_at = NOW()
WHERE id = $${values.length} WHERE id = $${values.length}

View File

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