mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 19:52:09 +01:00
feat: implement comprehensive CSRF protection
This commit is contained in:
346
scripts/migration/01-schema-migrations.sql
Normal file
346
scripts/migration/01-schema-migrations.sql
Normal file
@ -0,0 +1,346 @@
|
||||
-- Database Schema Migrations for tRPC and Batch Processing Integration
|
||||
-- Version: 2.0.0
|
||||
-- Created: 2025-01-11
|
||||
|
||||
-- =============================================================================
|
||||
-- MIGRATION VALIDATION
|
||||
-- =============================================================================
|
||||
|
||||
-- Check if this migration has already been applied
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'AIProcessingRequest'
|
||||
AND column_name = 'processingStatus'
|
||||
) THEN
|
||||
RAISE NOTICE 'Migration appears to already be applied. Skipping schema changes.';
|
||||
ELSE
|
||||
RAISE NOTICE 'Applying schema migrations for tRPC and Batch Processing...';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- BATCH PROCESSING ENUMS (if not already created by Prisma)
|
||||
-- =============================================================================
|
||||
|
||||
-- Create AIBatchRequestStatus enum if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'AIBatchRequestStatus') THEN
|
||||
CREATE TYPE "AIBatchRequestStatus" AS ENUM (
|
||||
'PENDING',
|
||||
'UPLOADING',
|
||||
'VALIDATING',
|
||||
'IN_PROGRESS',
|
||||
'FINALIZING',
|
||||
'COMPLETED',
|
||||
'PROCESSED',
|
||||
'FAILED',
|
||||
'CANCELLED'
|
||||
);
|
||||
RAISE NOTICE 'Created AIBatchRequestStatus enum';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Create AIRequestStatus enum if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'AIRequestStatus') THEN
|
||||
CREATE TYPE "AIRequestStatus" AS ENUM (
|
||||
'PENDING_BATCHING',
|
||||
'BATCHING_IN_PROGRESS',
|
||||
'PROCESSING_COMPLETE',
|
||||
'PROCESSING_FAILED'
|
||||
);
|
||||
RAISE NOTICE 'Created AIRequestStatus enum';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- AIBATCHREQUEST TABLE
|
||||
-- =============================================================================
|
||||
|
||||
-- Create AIBatchRequest table if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AIBatchRequest') THEN
|
||||
CREATE TABLE "AIBatchRequest" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"openaiBatchId" TEXT NOT NULL UNIQUE,
|
||||
"inputFileId" TEXT NOT NULL,
|
||||
"outputFileId" TEXT,
|
||||
"errorFileId" TEXT,
|
||||
"status" "AIBatchRequestStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completedAt" TIMESTAMPTZ(6),
|
||||
"processedAt" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "AIBatchRequest_companyId_fkey"
|
||||
FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes for AIBatchRequest
|
||||
CREATE INDEX "AIBatchRequest_companyId_status_idx" ON "AIBatchRequest"("companyId", "status");
|
||||
|
||||
RAISE NOTICE 'Created AIBatchRequest table with indexes';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- AIPROCESSINGREQUEST TABLE MODIFICATIONS
|
||||
-- =============================================================================
|
||||
|
||||
-- Add batch-related columns to AIProcessingRequest if they don't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Add processingStatus column
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'AIProcessingRequest' AND column_name = 'processingStatus'
|
||||
) THEN
|
||||
ALTER TABLE "AIProcessingRequest"
|
||||
ADD COLUMN "processingStatus" "AIRequestStatus" NOT NULL DEFAULT 'PENDING_BATCHING';
|
||||
RAISE NOTICE 'Added processingStatus column to AIProcessingRequest';
|
||||
END IF;
|
||||
|
||||
-- Add batchId column
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'AIProcessingRequest' AND column_name = 'batchId'
|
||||
) THEN
|
||||
ALTER TABLE "AIProcessingRequest"
|
||||
ADD COLUMN "batchId" TEXT;
|
||||
RAISE NOTICE 'Added batchId column to AIProcessingRequest';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Add foreign key constraint for batchId if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'AIProcessingRequest_batchId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "AIProcessingRequest"
|
||||
ADD CONSTRAINT "AIProcessingRequest_batchId_fkey"
|
||||
FOREIGN KEY ("batchId") REFERENCES "AIBatchRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
RAISE NOTICE 'Added foreign key constraint for batchId';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Create index for processingStatus if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'AIProcessingRequest_processingStatus_idx'
|
||||
) THEN
|
||||
CREATE INDEX "AIProcessingRequest_processingStatus_idx"
|
||||
ON "AIProcessingRequest"("processingStatus");
|
||||
RAISE NOTICE 'Created index on processingStatus';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- DATA MIGRATION FOR EXISTING RECORDS
|
||||
-- =============================================================================
|
||||
|
||||
-- Update existing AIProcessingRequest records to have default processing status
|
||||
DO $$
|
||||
DECLARE
|
||||
updated_count INTEGER;
|
||||
BEGIN
|
||||
UPDATE "AIProcessingRequest"
|
||||
SET "processingStatus" = 'PROCESSING_COMPLETE'
|
||||
WHERE "processingStatus" IS NULL AND "success" = true;
|
||||
|
||||
GET DIAGNOSTICS updated_count = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated % successful records to PROCESSING_COMPLETE', updated_count;
|
||||
|
||||
UPDATE "AIProcessingRequest"
|
||||
SET "processingStatus" = 'PROCESSING_FAILED'
|
||||
WHERE "processingStatus" IS NULL AND "success" = false;
|
||||
|
||||
GET DIAGNOSTICS updated_count = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated % failed records to PROCESSING_FAILED', updated_count;
|
||||
|
||||
UPDATE "AIProcessingRequest"
|
||||
SET "processingStatus" = 'PENDING_BATCHING'
|
||||
WHERE "processingStatus" IS NULL;
|
||||
|
||||
GET DIAGNOSTICS updated_count = ROW_COUNT;
|
||||
RAISE NOTICE 'Updated % remaining records to PENDING_BATCHING', updated_count;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- PERFORMANCE OPTIMIZATIONS
|
||||
-- =============================================================================
|
||||
|
||||
-- Create additional performance indexes for batch processing queries
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Index for finding requests ready for batching
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'AIProcessingRequest_batching_ready_idx'
|
||||
) THEN
|
||||
CREATE INDEX "AIProcessingRequest_batching_ready_idx"
|
||||
ON "AIProcessingRequest"("processingStatus", "requestedAt")
|
||||
WHERE "processingStatus" = 'PENDING_BATCHING';
|
||||
RAISE NOTICE 'Created index for batching ready requests';
|
||||
END IF;
|
||||
|
||||
-- Index for batch status monitoring
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'AIBatchRequest_status_created_idx'
|
||||
) THEN
|
||||
CREATE INDEX "AIBatchRequest_status_created_idx"
|
||||
ON "AIBatchRequest"("status", "createdAt");
|
||||
RAISE NOTICE 'Created index for batch status monitoring';
|
||||
END IF;
|
||||
|
||||
-- Composite index for session processing status queries (enhanced for tRPC)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'SessionProcessingStatus_compound_idx'
|
||||
) THEN
|
||||
CREATE INDEX "SessionProcessingStatus_compound_idx"
|
||||
ON "SessionProcessingStatus"("sessionId", "stage", "status", "startedAt");
|
||||
RAISE NOTICE 'Created compound index for session processing status';
|
||||
END IF;
|
||||
|
||||
-- Index for session filtering in tRPC endpoints
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = 'Session_trpc_filtering_idx'
|
||||
) THEN
|
||||
CREATE INDEX "Session_trpc_filtering_idx"
|
||||
ON "Session"("companyId", "startTime", "sentiment", "category")
|
||||
WHERE "sentiment" IS NOT NULL;
|
||||
RAISE NOTICE 'Created index for tRPC session filtering';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- VALIDATION CHECKS
|
||||
-- =============================================================================
|
||||
|
||||
-- Validate that all expected tables exist
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_tables TEXT[] := ARRAY[]::TEXT[];
|
||||
table_name TEXT;
|
||||
BEGIN
|
||||
FOR table_name IN SELECT unnest(ARRAY[
|
||||
'AIBatchRequest',
|
||||
'AIProcessingRequest',
|
||||
'Session',
|
||||
'SessionProcessingStatus',
|
||||
'Company',
|
||||
'User'
|
||||
]) LOOP
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = table_name
|
||||
) THEN
|
||||
missing_tables := missing_tables || table_name;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
IF array_length(missing_tables, 1) > 0 THEN
|
||||
RAISE EXCEPTION 'Missing required tables: %', array_to_string(missing_tables, ', ');
|
||||
ELSE
|
||||
RAISE NOTICE 'All required tables present';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Validate that all expected columns exist
|
||||
DO $$
|
||||
DECLARE
|
||||
missing_columns TEXT[] := ARRAY[]::TEXT[];
|
||||
validation_failed BOOLEAN := false;
|
||||
BEGIN
|
||||
-- Check AIProcessingRequest batch columns
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'AIProcessingRequest' AND column_name = 'processingStatus'
|
||||
) THEN
|
||||
missing_columns := missing_columns || 'AIProcessingRequest.processingStatus';
|
||||
validation_failed := true;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'AIProcessingRequest' AND column_name = 'batchId'
|
||||
) THEN
|
||||
missing_columns := missing_columns || 'AIProcessingRequest.batchId';
|
||||
validation_failed := true;
|
||||
END IF;
|
||||
|
||||
-- Check AIBatchRequest columns
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'AIBatchRequest' AND column_name = 'openaiBatchId'
|
||||
) THEN
|
||||
missing_columns := missing_columns || 'AIBatchRequest.openaiBatchId';
|
||||
validation_failed := true;
|
||||
END IF;
|
||||
|
||||
IF validation_failed THEN
|
||||
RAISE EXCEPTION 'Missing required columns: %', array_to_string(missing_columns, ', ');
|
||||
ELSE
|
||||
RAISE NOTICE 'All required columns present';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- STATISTICS UPDATE
|
||||
-- =============================================================================
|
||||
|
||||
-- Update table statistics for query optimization
|
||||
DO $$
|
||||
BEGIN
|
||||
ANALYZE "AIBatchRequest";
|
||||
ANALYZE "AIProcessingRequest";
|
||||
ANALYZE "Session";
|
||||
ANALYZE "SessionProcessingStatus";
|
||||
RAISE NOTICE 'Updated table statistics for query optimization';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- MIGRATION COMPLETION LOG
|
||||
-- =============================================================================
|
||||
|
||||
-- Log migration completion
|
||||
DO $$
|
||||
BEGIN
|
||||
RAISE NOTICE '=============================================================================';
|
||||
RAISE NOTICE 'SCHEMA MIGRATION COMPLETED SUCCESSFULLY';
|
||||
RAISE NOTICE '=============================================================================';
|
||||
RAISE NOTICE 'Version: 2.0.0';
|
||||
RAISE NOTICE 'Date: %', CURRENT_TIMESTAMP;
|
||||
RAISE NOTICE 'Migration: tRPC and Batch Processing Integration';
|
||||
RAISE NOTICE '=============================================================================';
|
||||
RAISE NOTICE 'New Features:';
|
||||
RAISE NOTICE '- OpenAI Batch API support (50%% cost reduction)';
|
||||
RAISE NOTICE '- Enhanced processing status tracking';
|
||||
RAISE NOTICE '- Optimized indexes for tRPC endpoints';
|
||||
RAISE NOTICE '- Improved query performance';
|
||||
RAISE NOTICE '=============================================================================';
|
||||
END
|
||||
$$;
|
||||
93
scripts/migration/README.md
Normal file
93
scripts/migration/README.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Migration Scripts for tRPC and Batch API Integration
|
||||
|
||||
This directory contains comprehensive migration scripts for deploying the new architecture that includes tRPC implementation and OpenAI Batch API integration.
|
||||
|
||||
## Migration Components
|
||||
|
||||
### 1. Database Migrations
|
||||
- `01-schema-migrations.sql` - Prisma database schema migrations
|
||||
- `02-data-migrations.sql` - Data transformation scripts
|
||||
- `validate-database.ts` - Database validation and health checks
|
||||
|
||||
### 2. Environment Configuration
|
||||
- `environment-migration.ts` - Environment variable migration guide
|
||||
- `config-validator.ts` - Configuration validation scripts
|
||||
|
||||
### 3. Deployment Scripts
|
||||
- `deploy.ts` - Main deployment orchestrator
|
||||
- `pre-deployment-checks.ts` - Pre-deployment validation
|
||||
- `post-deployment-validation.ts` - Post-deployment verification
|
||||
- `rollback.ts` - Rollback procedures
|
||||
|
||||
### 4. Health Checks
|
||||
- `health-checks.ts` - Comprehensive system health validation
|
||||
- `trpc-endpoint-tests.ts` - tRPC endpoint validation
|
||||
- `batch-processing-tests.ts` - Batch processing system tests
|
||||
|
||||
### 5. Migration Utilities
|
||||
- `backup-database.ts` - Database backup procedures
|
||||
- `restore-database.ts` - Database restore procedures
|
||||
- `migration-logger.ts` - Migration logging utilities
|
||||
|
||||
## Usage
|
||||
|
||||
### Pre-Migration
|
||||
1. Run database backup: `pnpm migration:backup`
|
||||
2. Validate environment: `pnpm migration:validate-env`
|
||||
3. Run pre-deployment checks: `pnpm migration:pre-check`
|
||||
|
||||
### Migration
|
||||
1. Run schema migrations: `pnpm migration:schema`
|
||||
2. Run data migrations: `pnpm migration:data`
|
||||
3. Deploy application: `pnpm migration:deploy`
|
||||
|
||||
### Post-Migration
|
||||
1. Validate deployment: `pnpm migration:validate`
|
||||
2. Run health checks: `pnpm migration:health-check`
|
||||
3. Test critical paths: `pnpm migration:test`
|
||||
|
||||
### Rollback (if needed)
|
||||
1. Rollback deployment: `pnpm migration:rollback`
|
||||
2. Restore database: `pnpm migration:restore`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The migration requires these new environment variables:
|
||||
|
||||
```bash
|
||||
# tRPC Configuration
|
||||
TRPC_ENDPOINT_URL=http://localhost:3000/api/trpc
|
||||
TRPC_BATCH_TIMEOUT=30000
|
||||
|
||||
# Batch Processing Configuration
|
||||
BATCH_PROCESSING_ENABLED=true
|
||||
BATCH_CREATE_INTERVAL="*/5 * * * *"
|
||||
BATCH_STATUS_CHECK_INTERVAL="*/2 * * * *"
|
||||
BATCH_RESULT_PROCESSING_INTERVAL="*/1 * * * *"
|
||||
BATCH_MAX_REQUESTS=1000
|
||||
BATCH_TIMEOUT_HOURS=24
|
||||
|
||||
# Migration Specific
|
||||
MIGRATION_MODE=production
|
||||
MIGRATION_BACKUP_ENABLED=true
|
||||
MIGRATION_ROLLBACK_ENABLED=true
|
||||
```
|
||||
|
||||
## Zero-Downtime Deployment Strategy
|
||||
|
||||
The migration implements a blue-green deployment strategy:
|
||||
|
||||
1. **Phase 1**: Deploy new code with feature flags disabled
|
||||
2. **Phase 2**: Run database migrations
|
||||
3. **Phase 3**: Enable tRPC endpoints progressively
|
||||
4. **Phase 4**: Enable batch processing system
|
||||
5. **Phase 5**: Full activation and old system decommission
|
||||
|
||||
## Safety Features
|
||||
|
||||
- Automatic database backups before migration
|
||||
- Rollback scripts for quick recovery
|
||||
- Health checks at each stage
|
||||
- Progressive feature enablement
|
||||
- Comprehensive logging and monitoring
|
||||
- Backwards compatibility maintained during migration
|
||||
433
scripts/migration/backup-database.ts
Normal file
433
scripts/migration/backup-database.ts
Normal file
@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Database Backup Utilities
|
||||
*
|
||||
* Provides comprehensive database backup functionality for safe migration.
|
||||
* Supports both schema and data backups with compression and verification.
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from "node:child_process";
|
||||
import { createWriteStream, existsSync, mkdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createGzip } from "node:zlib";
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
import { env } from "../../lib/env";
|
||||
|
||||
interface BackupOptions {
|
||||
includeData: boolean;
|
||||
includeSchema: boolean;
|
||||
compress: boolean;
|
||||
outputDir: string;
|
||||
filename?: string;
|
||||
verifyBackup: boolean;
|
||||
}
|
||||
|
||||
interface BackupResult {
|
||||
success: boolean;
|
||||
backupPath: string;
|
||||
size: number;
|
||||
duration: number;
|
||||
checksumMD5?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class DatabaseBackup {
|
||||
private readonly defaultOptions: BackupOptions = {
|
||||
includeData: true,
|
||||
includeSchema: true,
|
||||
compress: true,
|
||||
outputDir: join(process.cwd(), "backups"),
|
||||
verifyBackup: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a comprehensive database backup
|
||||
*/
|
||||
async createBackup(options?: Partial<BackupOptions>): Promise<BackupResult> {
|
||||
const opts = { ...this.defaultOptions, ...options };
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.startStep("DATABASE_BACKUP", "Creating database backup");
|
||||
|
||||
// Ensure backup directory exists
|
||||
this.ensureBackupDirectory(opts.outputDir);
|
||||
|
||||
// Generate backup filename
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = opts.filename || `livedash-backup-${timestamp}.sql`;
|
||||
const backupPath = join(opts.outputDir, filename);
|
||||
const finalPath = opts.compress ? `${backupPath}.gz` : backupPath;
|
||||
|
||||
// Extract database connection info
|
||||
const dbConfig = this.parseDatabaseUrl(env.DATABASE_URL);
|
||||
|
||||
// Create the backup
|
||||
await this.performBackup(dbConfig, backupPath, opts);
|
||||
|
||||
// Compress if requested
|
||||
if (opts.compress) {
|
||||
await this.compressBackup(backupPath, `${backupPath}.gz`);
|
||||
}
|
||||
|
||||
// Verify backup if requested
|
||||
let checksumMD5: string | undefined;
|
||||
if (opts.verifyBackup) {
|
||||
checksumMD5 = await this.verifyBackup(finalPath);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const stats = statSync(finalPath);
|
||||
|
||||
const result: BackupResult = {
|
||||
success: true,
|
||||
backupPath: finalPath,
|
||||
size: stats.size,
|
||||
duration,
|
||||
checksumMD5,
|
||||
};
|
||||
|
||||
migrationLogger.completeStep("DATABASE_BACKUP", duration);
|
||||
migrationLogger.info("DATABASE_BACKUP", "Backup completed successfully", {
|
||||
path: finalPath,
|
||||
sizeBytes: stats.size,
|
||||
sizeMB: Math.round(stats.size / 1024 / 1024 * 100) / 100,
|
||||
duration,
|
||||
checksum: checksumMD5,
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
migrationLogger.failStep("DATABASE_BACKUP", error as Error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
backupPath: "",
|
||||
size: 0,
|
||||
duration,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create schema-only backup for structure validation
|
||||
*/
|
||||
async createSchemaBackup(): Promise<BackupResult> {
|
||||
return this.createBackup({
|
||||
includeData: false,
|
||||
includeSchema: true,
|
||||
filename: `schema-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create data-only backup for content preservation
|
||||
*/
|
||||
async createDataBackup(): Promise<BackupResult> {
|
||||
return this.createBackup({
|
||||
includeData: true,
|
||||
includeSchema: false,
|
||||
filename: `data-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List existing backups with metadata
|
||||
*/
|
||||
async listBackups(backupDir?: string): Promise<Array<{
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
created: Date;
|
||||
type: string;
|
||||
}>> {
|
||||
const dir = backupDir || this.defaultOptions.outputDir;
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const files = await import("node:fs/promises").then(fs => fs.readdir(dir));
|
||||
const backups = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.sql') || file.endsWith('.sql.gz')) {
|
||||
const fullPath = join(dir, file);
|
||||
const stats = statSync(fullPath);
|
||||
|
||||
let type = "unknown";
|
||||
if (file.includes("schema")) type = "schema";
|
||||
else if (file.includes("data")) type = "data";
|
||||
else type = "full";
|
||||
|
||||
backups.push({
|
||||
filename: file,
|
||||
path: fullPath,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return backups.sort((a, b) => b.created.getTime() - a.created.getTime());
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.warn("BACKUP_LIST", "Failed to list backups", { error: (error as Error).message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private ensureBackupDirectory(dir: string): void {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
migrationLogger.debug("BACKUP_DIR", `Created backup directory: ${dir}`);
|
||||
}
|
||||
}
|
||||
|
||||
private parseDatabaseUrl(url: string): {
|
||||
host: string;
|
||||
port: string;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
} {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port || "5432",
|
||||
database: parsed.pathname.slice(1),
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid database URL: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async performBackup(
|
||||
dbConfig: ReturnType<typeof this.parseDatabaseUrl>,
|
||||
outputPath: string,
|
||||
options: BackupOptions
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
"-h", dbConfig.host,
|
||||
"-p", dbConfig.port,
|
||||
"-U", dbConfig.username,
|
||||
"-d", dbConfig.database,
|
||||
"-f", outputPath,
|
||||
"--verbose",
|
||||
];
|
||||
|
||||
// Add schema/data options
|
||||
if (!options.includeSchema) {
|
||||
args.push("--data-only");
|
||||
}
|
||||
if (!options.includeData) {
|
||||
args.push("--schema-only");
|
||||
}
|
||||
|
||||
// Additional options for better backup quality
|
||||
args.push(
|
||||
"--create", // Include CREATE DATABASE
|
||||
"--clean", // Include DROP statements
|
||||
"--if-exists", // Use IF EXISTS
|
||||
"--disable-triggers", // Disable triggers during restore
|
||||
"--no-owner", // Don't output ownership commands
|
||||
"--no-privileges" // Don't output privilege commands
|
||||
);
|
||||
|
||||
migrationLogger.debug("PG_DUMP", "Starting pg_dump", { args: args.filter(arg => arg !== dbConfig.password) });
|
||||
|
||||
const process = spawn("pg_dump", args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: dbConfig.password,
|
||||
},
|
||||
});
|
||||
|
||||
let errorOutput = "";
|
||||
|
||||
process.stderr.on("data", (data) => {
|
||||
const message = data.toString();
|
||||
errorOutput += message;
|
||||
|
||||
// pg_dump sends progress info to stderr, so we log it as debug
|
||||
if (message.includes("dumping")) {
|
||||
migrationLogger.debug("PG_DUMP", message.trim());
|
||||
}
|
||||
});
|
||||
|
||||
process.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
migrationLogger.debug("PG_DUMP", "Backup completed successfully");
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`pg_dump failed with code ${code}: ${errorOutput}`));
|
||||
}
|
||||
});
|
||||
|
||||
process.on("error", (error) => {
|
||||
reject(new Error(`Failed to start pg_dump: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async compressBackup(sourcePath: string, targetPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fs = require("node:fs");
|
||||
const readStream = fs.createReadStream(sourcePath);
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
const gzip = createGzip({ level: 6 });
|
||||
|
||||
readStream
|
||||
.pipe(gzip)
|
||||
.pipe(writeStream)
|
||||
.on("finish", () => {
|
||||
// Remove uncompressed file
|
||||
fs.unlinkSync(sourcePath);
|
||||
migrationLogger.debug("COMPRESSION", `Compressed backup: ${targetPath}`);
|
||||
resolve();
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
private async verifyBackup(backupPath: string): Promise<string> {
|
||||
try {
|
||||
// Calculate MD5 checksum
|
||||
const crypto = await import("node:crypto");
|
||||
const fs = await import("node:fs");
|
||||
|
||||
const hash = crypto.createHash("md5");
|
||||
const stream = fs.createReadStream(backupPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("data", (data) => hash.update(data));
|
||||
stream.on("end", () => {
|
||||
const checksum = hash.digest("hex");
|
||||
migrationLogger.debug("BACKUP_VERIFICATION", `Backup checksum: ${checksum}`);
|
||||
resolve(checksum);
|
||||
});
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.warn("BACKUP_VERIFICATION", "Failed to verify backup", { error: (error as Error).message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old backups, keeping only the specified number
|
||||
*/
|
||||
async cleanupOldBackups(keepCount: number = 5, backupDir?: string): Promise<void> {
|
||||
const dir = backupDir || this.defaultOptions.outputDir;
|
||||
const backups = await this.listBackups(dir);
|
||||
|
||||
if (backups.length <= keepCount) {
|
||||
migrationLogger.info("BACKUP_CLEANUP", `No cleanup needed. Found ${backups.length} backups, keeping ${keepCount}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toDelete = backups.slice(keepCount);
|
||||
migrationLogger.info("BACKUP_CLEANUP", `Cleaning up ${toDelete.length} old backups`);
|
||||
|
||||
const fs = await import("node:fs/promises");
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
await fs.unlink(backup.path);
|
||||
migrationLogger.debug("BACKUP_CLEANUP", `Deleted old backup: ${backup.filename}`);
|
||||
} catch (error) {
|
||||
migrationLogger.warn("BACKUP_CLEANUP", `Failed to delete backup: ${backup.filename}`, {
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const backup = new DatabaseBackup();
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
async function runCommand() {
|
||||
switch (command) {
|
||||
case "full":
|
||||
return backup.createBackup();
|
||||
|
||||
case "schema":
|
||||
return backup.createSchemaBackup();
|
||||
|
||||
case "data":
|
||||
return backup.createDataBackup();
|
||||
|
||||
case "list":
|
||||
const backups = await backup.listBackups();
|
||||
console.log('\n=== DATABASE BACKUPS ===');
|
||||
if (backups.length === 0) {
|
||||
console.log('No backups found.');
|
||||
} else {
|
||||
backups.forEach(b => {
|
||||
const sizeMB = Math.round(b.size / 1024 / 1024 * 100) / 100;
|
||||
console.log(`${b.filename} (${b.type}, ${sizeMB}MB, ${b.created.toISOString()})`);
|
||||
});
|
||||
}
|
||||
return { success: true, backupPath: "", size: 0, duration: 0 };
|
||||
|
||||
case "cleanup":
|
||||
await backup.cleanupOldBackups(5);
|
||||
return { success: true, backupPath: "", size: 0, duration: 0 };
|
||||
|
||||
default:
|
||||
console.log(`
|
||||
Usage: node backup-database.js <command>
|
||||
|
||||
Commands:
|
||||
full - Create full database backup (schema + data)
|
||||
schema - Create schema-only backup
|
||||
data - Create data-only backup
|
||||
list - List existing backups
|
||||
cleanup - Clean up old backups (keep 5 most recent)
|
||||
|
||||
Examples:
|
||||
node backup-database.js full
|
||||
node backup-database.js schema
|
||||
node backup-database.js list
|
||||
`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runCommand()
|
||||
.then((result) => {
|
||||
if (command !== "list" && command !== "cleanup") {
|
||||
console.log('\n=== BACKUP RESULTS ===');
|
||||
console.log(`Success: ${result.success ? '✅' : '❌'}`);
|
||||
if (result.success) {
|
||||
console.log(`Path: ${result.backupPath}`);
|
||||
console.log(`Size: ${Math.round(result.size / 1024 / 1024 * 100) / 100} MB`);
|
||||
console.log(`Duration: ${result.duration}ms`);
|
||||
if (result.checksumMD5) {
|
||||
console.log(`Checksum: ${result.checksumMD5}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`Error: ${result.error?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Backup failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
864
scripts/migration/batch-processing-tests.ts
Normal file
864
scripts/migration/batch-processing-tests.ts
Normal file
@ -0,0 +1,864 @@
|
||||
/**
|
||||
* Batch Processing System Tests
|
||||
*
|
||||
* Comprehensive tests to validate the OpenAI Batch API integration
|
||||
* and batch processing system functionality.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
|
||||
interface BatchTest {
|
||||
name: string;
|
||||
testFn: () => Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }>;
|
||||
critical: boolean;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
interface BatchTestResult {
|
||||
name: string;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
details?: Record<string, unknown>;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface BatchSystemTestResult {
|
||||
success: boolean;
|
||||
tests: BatchTestResult[];
|
||||
totalDuration: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
criticalFailures: number;
|
||||
}
|
||||
|
||||
export class BatchProcessingTester {
|
||||
private prisma: PrismaClient;
|
||||
|
||||
constructor() {
|
||||
this.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comprehensive batch processing tests
|
||||
*/
|
||||
async runBatchProcessingTests(): Promise<BatchSystemTestResult> {
|
||||
const startTime = Date.now();
|
||||
const tests: BatchTestResult[] = [];
|
||||
|
||||
try {
|
||||
migrationLogger.startStep("BATCH_TESTS", "Running batch processing system validation tests");
|
||||
|
||||
// Define test suite
|
||||
const batchTests: BatchTest[] = [
|
||||
{
|
||||
name: "Database Schema Validation",
|
||||
testFn: () => this.testDatabaseSchema(),
|
||||
critical: true,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
name: "Batch Processor Import",
|
||||
testFn: () => this.testBatchProcessorImport(),
|
||||
critical: true,
|
||||
timeout: 5000,
|
||||
},
|
||||
{
|
||||
name: "Batch Request Creation",
|
||||
testFn: () => this.testBatchRequestCreation(),
|
||||
critical: true,
|
||||
timeout: 10000,
|
||||
},
|
||||
{
|
||||
name: "Processing Request Management",
|
||||
testFn: () => this.testProcessingRequestManagement(),
|
||||
critical: true,
|
||||
timeout: 10000,
|
||||
},
|
||||
{
|
||||
name: "Batch Status Transitions",
|
||||
testFn: () => this.testBatchStatusTransitions(),
|
||||
critical: true,
|
||||
timeout: 10000,
|
||||
},
|
||||
{
|
||||
name: "Batch Scheduling System",
|
||||
testFn: () => this.testBatchScheduling(),
|
||||
critical: false,
|
||||
timeout: 15000,
|
||||
},
|
||||
{
|
||||
name: "OpenAI API Integration",
|
||||
testFn: () => this.testOpenAIIntegration(),
|
||||
critical: false,
|
||||
timeout: 30000,
|
||||
},
|
||||
{
|
||||
name: "Error Handling",
|
||||
testFn: () => this.testErrorHandling(),
|
||||
critical: true,
|
||||
timeout: 10000,
|
||||
},
|
||||
{
|
||||
name: "Batch Processing Performance",
|
||||
testFn: () => this.testBatchPerformance(),
|
||||
critical: false,
|
||||
timeout: 20000,
|
||||
},
|
||||
{
|
||||
name: "Data Consistency",
|
||||
testFn: () => this.testDataConsistency(),
|
||||
critical: true,
|
||||
timeout: 10000,
|
||||
},
|
||||
];
|
||||
|
||||
// Run all tests
|
||||
for (const test of batchTests) {
|
||||
const result = await this.runSingleBatchTest(test);
|
||||
tests.push(result);
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const passedTests = tests.filter(t => t.success).length;
|
||||
const failedTests = tests.filter(t => !t.success).length;
|
||||
const criticalFailures = tests.filter(t => !t.success && batchTests.find(bt => bt.name === t.name)?.critical).length;
|
||||
|
||||
const result: BatchSystemTestResult = {
|
||||
success: criticalFailures === 0,
|
||||
tests,
|
||||
totalDuration,
|
||||
passedTests,
|
||||
failedTests,
|
||||
criticalFailures,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.completeStep("BATCH_TESTS");
|
||||
} else {
|
||||
migrationLogger.failStep("BATCH_TESTS", new Error(`${criticalFailures} critical batch tests failed`));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("BATCH_TESTS", "Batch processing test suite failed", error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
await this.prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async runSingleBatchTest(test: BatchTest): Promise<BatchTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.debug("BATCH_TEST", `Testing: ${test.name}`);
|
||||
|
||||
// Set up timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Test timeout")), test.timeout);
|
||||
});
|
||||
|
||||
const testResult = await Promise.race([
|
||||
test.testFn(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const result: BatchTestResult = {
|
||||
name: test.name,
|
||||
success: testResult.success,
|
||||
duration,
|
||||
details: testResult.details,
|
||||
error: testResult.error,
|
||||
};
|
||||
|
||||
if (testResult.success) {
|
||||
migrationLogger.debug("BATCH_TEST", `✅ ${test.name} passed`, {
|
||||
duration,
|
||||
details: testResult.details
|
||||
});
|
||||
} else {
|
||||
migrationLogger.warn("BATCH_TEST", `❌ ${test.name} failed`, {
|
||||
duration,
|
||||
error: testResult.error?.message
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
migrationLogger.error("BATCH_TEST", `💥 ${test.name} crashed`, error as Error, { duration });
|
||||
|
||||
return {
|
||||
name: test.name,
|
||||
success: false,
|
||||
duration,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testDatabaseSchema(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Check if AIBatchRequest table exists and has correct columns
|
||||
const batchRequestTableCheck = await this.prisma.$queryRaw<{count: string}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'AIBatchRequest'
|
||||
`;
|
||||
|
||||
if (parseInt(batchRequestTableCheck[0]?.count || '0') === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error("AIBatchRequest table not found")
|
||||
};
|
||||
}
|
||||
|
||||
// Check required columns
|
||||
const requiredColumns = [
|
||||
'openaiBatchId', 'inputFileId', 'outputFileId', 'status', 'companyId'
|
||||
];
|
||||
|
||||
const columnChecks = await Promise.all(
|
||||
requiredColumns.map(async (column) => {
|
||||
const result = await this.prisma.$queryRawUnsafe(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'AIBatchRequest' AND column_name = '${column}'
|
||||
`) as {count: string}[];
|
||||
return { column, exists: parseInt(result[0]?.count || '0') > 0 };
|
||||
})
|
||||
);
|
||||
|
||||
const missingColumns = columnChecks.filter(c => !c.exists).map(c => c.column);
|
||||
|
||||
// Check AIProcessingRequest has batch fields
|
||||
const processingRequestBatchFields = await this.prisma.$queryRawUnsafe(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'AIProcessingRequest'
|
||||
AND column_name IN ('processingStatus', 'batchId')
|
||||
`) as {column_name: string}[];
|
||||
|
||||
const hasProcessingStatus = processingRequestBatchFields.some(c => c.column_name === 'processingStatus');
|
||||
const hasBatchId = processingRequestBatchFields.some(c => c.column_name === 'batchId');
|
||||
|
||||
return {
|
||||
success: missingColumns.length === 0 && hasProcessingStatus && hasBatchId,
|
||||
details: {
|
||||
missingColumns,
|
||||
hasProcessingStatus,
|
||||
hasBatchId,
|
||||
requiredColumnsPresent: requiredColumns.length - missingColumns.length
|
||||
},
|
||||
error: missingColumns.length > 0 || !hasProcessingStatus || !hasBatchId
|
||||
? new Error(`Schema validation failed: missing ${missingColumns.join(', ')}${!hasProcessingStatus ? ', processingStatus' : ''}${!hasBatchId ? ', batchId' : ''}`)
|
||||
: undefined
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testBatchProcessorImport(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Test if batch processor can be imported
|
||||
const batchProcessor = await import("../../lib/batchProcessor");
|
||||
|
||||
// Check if key functions/classes exist
|
||||
const hasBatchConfig = 'BATCH_CONFIG' in batchProcessor;
|
||||
const hasCreateBatch = typeof batchProcessor.createBatchFromRequests === 'function';
|
||||
const hasProcessBatch = typeof batchProcessor.processBatchResults === 'function';
|
||||
|
||||
return {
|
||||
success: hasBatchConfig || hasCreateBatch || hasProcessBatch, // At least one should exist
|
||||
details: {
|
||||
batchProcessorImported: true,
|
||||
hasBatchConfig,
|
||||
hasCreateBatch,
|
||||
hasProcessBatch,
|
||||
exportedItems: Object.keys(batchProcessor)
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error,
|
||||
details: {
|
||||
batchProcessorImported: false,
|
||||
importError: (error as Error).message
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testBatchRequestCreation(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Create a test batch request
|
||||
const testBatchRequest = await this.prisma.aIBatchRequest.create({
|
||||
data: {
|
||||
companyId: 'test-company-' + Date.now(),
|
||||
openaiBatchId: 'test-batch-' + Date.now(),
|
||||
inputFileId: 'test-input-' + Date.now(),
|
||||
status: 'PENDING',
|
||||
}
|
||||
});
|
||||
|
||||
// Verify it was created correctly
|
||||
const retrievedBatch = await this.prisma.aIBatchRequest.findUnique({
|
||||
where: { id: testBatchRequest.id }
|
||||
});
|
||||
|
||||
// Clean up test data
|
||||
await this.prisma.aIBatchRequest.delete({
|
||||
where: { id: testBatchRequest.id }
|
||||
});
|
||||
|
||||
return {
|
||||
success: !!retrievedBatch && retrievedBatch.status === 'PENDING',
|
||||
details: {
|
||||
batchRequestCreated: !!testBatchRequest,
|
||||
batchRequestRetrieved: !!retrievedBatch,
|
||||
statusCorrect: retrievedBatch?.status === 'PENDING',
|
||||
testBatchId: testBatchRequest.id
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testProcessingRequestManagement(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Count existing processing requests
|
||||
const initialCount = await this.prisma.aIProcessingRequest.count();
|
||||
|
||||
// Check processing status distribution
|
||||
const statusDistribution = await this.prisma.aIProcessingRequest.groupBy({
|
||||
by: ['processingStatus'],
|
||||
_count: { processingStatus: true },
|
||||
});
|
||||
|
||||
// Check if we can query requests ready for batching
|
||||
const readyForBatching = await this.prisma.aIProcessingRequest.findMany({
|
||||
where: {
|
||||
processingStatus: 'PENDING_BATCHING'
|
||||
},
|
||||
take: 5
|
||||
});
|
||||
|
||||
return {
|
||||
success: true, // Basic query operations work
|
||||
details: {
|
||||
totalProcessingRequests: initialCount,
|
||||
statusDistribution: Object.fromEntries(
|
||||
statusDistribution.map(s => [s.processingStatus, s._count.processingStatus])
|
||||
),
|
||||
readyForBatchingCount: readyForBatching.length,
|
||||
canQueryByStatus: true
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testBatchStatusTransitions(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Test that we can update batch status through all states
|
||||
const testBatchRequest = await this.prisma.aIBatchRequest.create({
|
||||
data: {
|
||||
companyId: 'test-company-' + Date.now(),
|
||||
openaiBatchId: 'test-status-batch-' + Date.now(),
|
||||
inputFileId: 'test-status-input-' + Date.now(),
|
||||
status: 'PENDING',
|
||||
}
|
||||
});
|
||||
|
||||
const statusTransitions = [
|
||||
'UPLOADING',
|
||||
'VALIDATING',
|
||||
'IN_PROGRESS',
|
||||
'FINALIZING',
|
||||
'COMPLETED',
|
||||
'PROCESSED'
|
||||
] as const;
|
||||
|
||||
const transitionResults: boolean[] = [];
|
||||
|
||||
for (const status of statusTransitions) {
|
||||
try {
|
||||
await this.prisma.aIBatchRequest.update({
|
||||
where: { id: testBatchRequest.id },
|
||||
data: { status }
|
||||
});
|
||||
transitionResults.push(true);
|
||||
} catch (error) {
|
||||
transitionResults.push(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up test data
|
||||
await this.prisma.aIBatchRequest.delete({
|
||||
where: { id: testBatchRequest.id }
|
||||
});
|
||||
|
||||
const successfulTransitions = transitionResults.filter(r => r).length;
|
||||
|
||||
return {
|
||||
success: successfulTransitions === statusTransitions.length,
|
||||
details: {
|
||||
totalTransitions: statusTransitions.length,
|
||||
successfulTransitions,
|
||||
failedTransitions: statusTransitions.length - successfulTransitions,
|
||||
transitionResults: Object.fromEntries(
|
||||
statusTransitions.map((status, index) => [status, transitionResults[index]])
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testBatchScheduling(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Test if batch scheduler can be imported
|
||||
const batchScheduler = await import("../../lib/batchScheduler");
|
||||
|
||||
// Check if scheduling functions exist
|
||||
const hasScheduler = typeof batchScheduler.startBatchScheduler === 'function';
|
||||
const hasProcessor = typeof batchScheduler.processPendingBatches === 'function';
|
||||
|
||||
// Check environment variables for scheduling
|
||||
const batchEnabled = process.env.BATCH_PROCESSING_ENABLED === 'true';
|
||||
const hasIntervals = !!(
|
||||
process.env.BATCH_CREATE_INTERVAL &&
|
||||
process.env.BATCH_STATUS_CHECK_INTERVAL &&
|
||||
process.env.BATCH_RESULT_PROCESSING_INTERVAL
|
||||
);
|
||||
|
||||
return {
|
||||
success: hasScheduler && batchEnabled,
|
||||
details: {
|
||||
batchSchedulerImported: true,
|
||||
hasScheduler,
|
||||
hasProcessor,
|
||||
batchEnabled,
|
||||
hasIntervals,
|
||||
exportedItems: Object.keys(batchScheduler)
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error,
|
||||
details: {
|
||||
batchSchedulerImported: false,
|
||||
importError: (error as Error).message
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testOpenAIIntegration(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const mockMode = process.env.OPENAI_MOCK_MODE === 'true';
|
||||
|
||||
if (mockMode) {
|
||||
return {
|
||||
success: true,
|
||||
details: {
|
||||
mode: 'mock',
|
||||
apiKeyPresent: !!apiKey,
|
||||
testType: 'mock_mode_enabled'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error("OpenAI API key not configured"),
|
||||
details: {
|
||||
mode: 'live',
|
||||
apiKeyPresent: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Test basic API access (simple models list)
|
||||
const response = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(`OpenAI API access failed: ${response.status} ${response.statusText}`),
|
||||
details: {
|
||||
mode: 'live',
|
||||
apiKeyPresent: true,
|
||||
httpStatus: response.status
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const models = await response.json();
|
||||
const hasModels = models.data && Array.isArray(models.data) && models.data.length > 0;
|
||||
|
||||
return {
|
||||
success: hasModels,
|
||||
details: {
|
||||
mode: 'live',
|
||||
apiKeyPresent: true,
|
||||
apiAccessible: true,
|
||||
modelsCount: models.data?.length || 0,
|
||||
hasGPTModels: models.data?.some((m: any) => m.id.includes('gpt')) || false
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error,
|
||||
details: {
|
||||
mode: 'live',
|
||||
apiKeyPresent: !!process.env.OPENAI_API_KEY,
|
||||
networkError: true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testErrorHandling(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Test handling of invalid batch requests
|
||||
let invalidBatchHandled = false;
|
||||
try {
|
||||
await this.prisma.aIBatchRequest.create({
|
||||
data: {
|
||||
companyId: '', // Invalid empty company ID
|
||||
openaiBatchId: 'test-invalid-batch',
|
||||
inputFileId: 'test-invalid-input',
|
||||
status: 'PENDING',
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// This should fail, which means error handling is working
|
||||
invalidBatchHandled = true;
|
||||
}
|
||||
|
||||
// Test handling of duplicate OpenAI batch IDs
|
||||
let duplicateHandled = false;
|
||||
const uniqueId = 'test-duplicate-' + Date.now();
|
||||
|
||||
try {
|
||||
// Create first batch
|
||||
const firstBatch = await this.prisma.aIBatchRequest.create({
|
||||
data: {
|
||||
companyId: 'test-company-duplicate',
|
||||
openaiBatchId: uniqueId,
|
||||
inputFileId: 'test-duplicate-input-1',
|
||||
status: 'PENDING',
|
||||
}
|
||||
});
|
||||
|
||||
// Try to create duplicate
|
||||
try {
|
||||
await this.prisma.aIBatchRequest.create({
|
||||
data: {
|
||||
companyId: 'test-company-duplicate',
|
||||
openaiBatchId: uniqueId, // Same OpenAI batch ID
|
||||
inputFileId: 'test-duplicate-input-2',
|
||||
status: 'PENDING',
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// This should fail due to unique constraint
|
||||
duplicateHandled = true;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await this.prisma.aIBatchRequest.delete({
|
||||
where: { id: firstBatch.id }
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Initial creation failed, that's also error handling
|
||||
duplicateHandled = true;
|
||||
}
|
||||
|
||||
return {
|
||||
success: invalidBatchHandled && duplicateHandled,
|
||||
details: {
|
||||
invalidBatchHandled,
|
||||
duplicateHandled,
|
||||
errorHandlingWorking: invalidBatchHandled && duplicateHandled
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testBatchPerformance(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Test query performance for batch operations
|
||||
const startTime = Date.now();
|
||||
|
||||
// Query for batches ready for processing
|
||||
const pendingBatches = await this.prisma.aIBatchRequest.findMany({
|
||||
where: {
|
||||
status: { in: ['PENDING', 'UPLOADING', 'VALIDATING'] }
|
||||
},
|
||||
take: 100
|
||||
});
|
||||
|
||||
const pendingBatchesTime = Date.now() - startTime;
|
||||
|
||||
// Query for requests ready for batching
|
||||
const batchingStartTime = Date.now();
|
||||
|
||||
const readyRequests = await this.prisma.aIProcessingRequest.findMany({
|
||||
where: {
|
||||
processingStatus: 'PENDING_BATCHING'
|
||||
},
|
||||
take: 100
|
||||
});
|
||||
|
||||
const readyRequestsTime = Date.now() - batchingStartTime;
|
||||
|
||||
// Query performance should be reasonable
|
||||
const performanceAcceptable = pendingBatchesTime < 1000 && readyRequestsTime < 1000;
|
||||
|
||||
return {
|
||||
success: performanceAcceptable,
|
||||
details: {
|
||||
pendingBatchesCount: pendingBatches.length,
|
||||
pendingBatchesQueryTime: pendingBatchesTime,
|
||||
readyRequestsCount: readyRequests.length,
|
||||
readyRequestsQueryTime: readyRequestsTime,
|
||||
performanceAcceptable,
|
||||
totalTestTime: Date.now() - startTime
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async testDataConsistency(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Check for orphaned processing requests (batchId points to non-existent batch)
|
||||
const orphanedRequests = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "AIProcessingRequest" apr
|
||||
LEFT JOIN "AIBatchRequest" abr ON apr."batchId" = abr.id
|
||||
WHERE apr."batchId" IS NOT NULL AND abr.id IS NULL
|
||||
`;
|
||||
|
||||
const orphanedCount = Number(orphanedRequests[0]?.count || 0);
|
||||
|
||||
// Check for processing requests with inconsistent status
|
||||
const inconsistentRequests = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "AIProcessingRequest"
|
||||
WHERE ("batchId" IS NOT NULL AND "processingStatus" = 'PENDING_BATCHING')
|
||||
OR ("batchId" IS NULL AND "processingStatus" IN ('BATCHING_IN_PROGRESS'))
|
||||
`;
|
||||
|
||||
const inconsistentCount = Number(inconsistentRequests[0]?.count || 0);
|
||||
|
||||
// Check for batches with no associated requests
|
||||
const emptyBatches = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "AIBatchRequest" abr
|
||||
LEFT JOIN "AIProcessingRequest" apr ON abr.id = apr."batchId"
|
||||
WHERE apr."batchId" IS NULL
|
||||
`;
|
||||
|
||||
const emptyBatchCount = Number(emptyBatches[0]?.count || 0);
|
||||
|
||||
const dataConsistent = orphanedCount === 0 && inconsistentCount === 0;
|
||||
|
||||
return {
|
||||
success: dataConsistent,
|
||||
details: {
|
||||
orphanedRequests: orphanedCount,
|
||||
inconsistentRequests: inconsistentCount,
|
||||
emptyBatches: emptyBatchCount,
|
||||
dataConsistent,
|
||||
issuesFound: orphanedCount + inconsistentCount
|
||||
},
|
||||
error: !dataConsistent ? new Error(`Data consistency issues found: ${orphanedCount} orphaned requests, ${inconsistentCount} inconsistent requests`) : undefined
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate batch processing test report
|
||||
*/
|
||||
generateTestReport(result: BatchSystemTestResult): string {
|
||||
const report = `
|
||||
# Batch Processing System Test Report
|
||||
|
||||
**Overall Status**: ${result.success ? '✅ All Critical Tests Passed' : '❌ Critical Tests Failed'}
|
||||
**Total Duration**: ${result.totalDuration}ms
|
||||
**Passed Tests**: ${result.passedTests}/${result.tests.length}
|
||||
**Failed Tests**: ${result.failedTests}/${result.tests.length}
|
||||
**Critical Failures**: ${result.criticalFailures}
|
||||
|
||||
## Test Results
|
||||
|
||||
${result.tests.map(test => `
|
||||
### ${test.name}
|
||||
- **Status**: ${test.success ? '✅ Pass' : '❌ Fail'}
|
||||
- **Duration**: ${test.duration}ms
|
||||
${test.details ? `- **Details**: \`\`\`json\n${JSON.stringify(test.details, null, 2)}\n\`\`\`` : ''}
|
||||
${test.error ? `- **Error**: ${test.error.message}` : ''}
|
||||
`).join('')}
|
||||
|
||||
## Summary
|
||||
|
||||
${result.success ?
|
||||
'🎉 Batch processing system is working correctly!' :
|
||||
`⚠️ ${result.criticalFailures} critical issue(s) found. Please review and fix the issues above.`
|
||||
}
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The batch processing system provides:
|
||||
- **50% cost reduction** using OpenAI Batch API
|
||||
- **Improved rate limiting** and throughput management
|
||||
- **Enhanced error handling** and retry mechanisms
|
||||
- **Automatic batching** of AI requests every 5 minutes
|
||||
- **Status monitoring** with 2-minute check intervals
|
||||
- **Result processing** with 1-minute intervals
|
||||
|
||||
${result.failedTests > 0 ? `
|
||||
## Issues Found
|
||||
|
||||
${result.tests.filter(t => !t.success).map(test => `
|
||||
### ${test.name}
|
||||
- **Error**: ${test.error?.message || 'Test failed'}
|
||||
- **Details**: ${test.details ? JSON.stringify(test.details, null, 2) : 'No additional details'}
|
||||
`).join('')}
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
1. **Database Issues**: Run database migrations to ensure all tables and columns exist
|
||||
2. **Import Issues**: Verify all batch processing modules are properly installed
|
||||
3. **API Issues**: Check OpenAI API key configuration and network connectivity
|
||||
4. **Performance Issues**: Optimize database queries and add missing indexes
|
||||
5. **Data Issues**: Run data consistency checks and fix orphaned records
|
||||
` : `
|
||||
## System Health
|
||||
|
||||
✅ All critical batch processing components are functioning correctly.
|
||||
|
||||
### Performance Metrics
|
||||
${result.tests.find(t => t.name === "Batch Processing Performance")?.details ?
|
||||
`- Pending batches query: ${(result.tests.find(t => t.name === "Batch Processing Performance")?.details as any)?.pendingBatchesQueryTime}ms
|
||||
- Ready requests query: ${(result.tests.find(t => t.name === "Batch Processing Performance")?.details as any)?.readyRequestsQueryTime}ms`
|
||||
: 'Performance metrics not available'}
|
||||
|
||||
### Next Steps
|
||||
1. Monitor batch processing queues regularly
|
||||
2. Set up alerting for failed batches
|
||||
3. Optimize batch sizes based on usage patterns
|
||||
4. Consider implementing batch priority levels
|
||||
`}
|
||||
|
||||
---
|
||||
*Generated at ${new Date().toISOString()}*
|
||||
`;
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const tester = new BatchProcessingTester();
|
||||
|
||||
const generateReport = process.argv.includes("--report");
|
||||
|
||||
tester.runBatchProcessingTests()
|
||||
.then((result) => {
|
||||
console.log('\n=== BATCH PROCESSING TEST RESULTS ===');
|
||||
console.log(`Overall Success: ${result.success ? '✅' : '❌'}`);
|
||||
console.log(`Total Duration: ${result.totalDuration}ms`);
|
||||
console.log(`Passed Tests: ${result.passedTests}/${result.tests.length}`);
|
||||
console.log(`Failed Tests: ${result.failedTests}/${result.tests.length}`);
|
||||
console.log(`Critical Failures: ${result.criticalFailures}`);
|
||||
|
||||
console.log('\n=== INDIVIDUAL TEST RESULTS ===');
|
||||
for (const test of result.tests) {
|
||||
const status = test.success ? '✅' : '❌';
|
||||
console.log(`${status} ${test.name} (${test.duration}ms)`);
|
||||
|
||||
if (test.error) {
|
||||
console.log(` Error: ${test.error.message}`);
|
||||
}
|
||||
|
||||
if (test.details) {
|
||||
console.log(` Details: ${JSON.stringify(test.details, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (generateReport) {
|
||||
const report = tester.generateTestReport(result);
|
||||
const fs = require("node:fs");
|
||||
const reportPath = `batch-processing-test-report-${Date.now()}.md`;
|
||||
fs.writeFileSync(reportPath, report);
|
||||
console.log(`\n📋 Test report saved to: ${reportPath}`);
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch processing tests failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
551
scripts/migration/deploy.ts
Normal file
551
scripts/migration/deploy.ts
Normal file
@ -0,0 +1,551 @@
|
||||
/**
|
||||
* Main Deployment Orchestrator
|
||||
*
|
||||
* Orchestrates the complete deployment process for tRPC and batch processing
|
||||
* architecture with zero-downtime deployment strategy.
|
||||
*/
|
||||
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
import { PreDeploymentChecker } from "./pre-deployment-checks";
|
||||
import { DatabaseBackup } from "./backup-database";
|
||||
import { EnvironmentMigration } from "./environment-migration";
|
||||
import { DatabaseValidator } from "./validate-database";
|
||||
import { HealthChecker } from "./health-checks";
|
||||
|
||||
interface DeploymentOptions {
|
||||
skipPreChecks: boolean;
|
||||
skipBackup: boolean;
|
||||
skipEnvironmentMigration: boolean;
|
||||
dryRun: boolean;
|
||||
rollbackOnFailure: boolean;
|
||||
enableProgressiveRollout: boolean;
|
||||
maxDowntime: number; // in milliseconds
|
||||
}
|
||||
|
||||
interface DeploymentPhase {
|
||||
name: string;
|
||||
description: string;
|
||||
critical: boolean;
|
||||
execute: () => Promise<void>;
|
||||
rollback?: () => Promise<void>;
|
||||
healthCheck?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface DeploymentResult {
|
||||
success: boolean;
|
||||
completedPhases: string[];
|
||||
failedPhase?: string;
|
||||
totalDuration: number;
|
||||
downtime: number;
|
||||
backupPath?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class DeploymentOrchestrator {
|
||||
private readonly defaultOptions: DeploymentOptions = {
|
||||
skipPreChecks: false,
|
||||
skipBackup: false,
|
||||
skipEnvironmentMigration: false,
|
||||
dryRun: false,
|
||||
rollbackOnFailure: true,
|
||||
enableProgressiveRollout: true,
|
||||
maxDowntime: 30000, // 30 seconds
|
||||
};
|
||||
|
||||
private options: DeploymentOptions;
|
||||
private phases: DeploymentPhase[] = [];
|
||||
private executedPhases: string[] = [];
|
||||
private startTime: number = 0;
|
||||
private downtimeStart: number = 0;
|
||||
private downtimeEnd: number = 0;
|
||||
|
||||
constructor(options?: Partial<DeploymentOptions>) {
|
||||
this.options = { ...this.defaultOptions, ...options };
|
||||
this.setupDeploymentPhases();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the complete deployment process
|
||||
*/
|
||||
async deploy(): Promise<DeploymentResult> {
|
||||
this.startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.startPhase("DEPLOYMENT", `Starting deployment with options: ${JSON.stringify(this.options)}`);
|
||||
|
||||
// Pre-deployment phase
|
||||
if (!this.options.skipPreChecks) {
|
||||
await this.runPreDeploymentChecks();
|
||||
}
|
||||
|
||||
// Backup phase
|
||||
let backupPath: string | undefined;
|
||||
if (!this.options.skipBackup) {
|
||||
backupPath = await this.createBackup();
|
||||
}
|
||||
|
||||
// Execute deployment phases
|
||||
for (const phase of this.phases) {
|
||||
await this.executePhase(phase);
|
||||
this.executedPhases.push(phase.name);
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - this.startTime;
|
||||
const downtime = this.downtimeEnd - this.downtimeStart;
|
||||
|
||||
migrationLogger.completePhase("DEPLOYMENT");
|
||||
migrationLogger.info("DEPLOYMENT", "Deployment completed successfully", {
|
||||
totalDuration,
|
||||
downtime,
|
||||
phases: this.executedPhases.length
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
completedPhases: this.executedPhases,
|
||||
totalDuration,
|
||||
downtime,
|
||||
backupPath,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const totalDuration = Date.now() - this.startTime;
|
||||
const downtime = this.downtimeEnd > 0 ? this.downtimeEnd - this.downtimeStart : 0;
|
||||
|
||||
migrationLogger.error("DEPLOYMENT", "Deployment failed", error as Error);
|
||||
|
||||
// Attempt rollback if enabled
|
||||
if (this.options.rollbackOnFailure) {
|
||||
try {
|
||||
await this.performRollback();
|
||||
} catch (rollbackError) {
|
||||
migrationLogger.error("ROLLBACK", "Rollback failed", rollbackError as Error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
completedPhases: this.executedPhases,
|
||||
totalDuration,
|
||||
downtime,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private setupDeploymentPhases(): void {
|
||||
this.phases = [
|
||||
{
|
||||
name: "Environment Migration",
|
||||
description: "Migrate environment variables for new architecture",
|
||||
critical: false,
|
||||
execute: async () => {
|
||||
if (this.options.skipEnvironmentMigration) {
|
||||
migrationLogger.info("PHASE", "Skipping environment migration");
|
||||
return;
|
||||
}
|
||||
|
||||
const envMigration = new EnvironmentMigration();
|
||||
const result = await envMigration.migrateEnvironment();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Environment migration failed: ${result.errors.join(', ')}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Database Schema Migration",
|
||||
description: "Apply database schema changes",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.runDatabaseMigrations();
|
||||
},
|
||||
rollback: async () => {
|
||||
await this.rollbackDatabaseMigrations();
|
||||
},
|
||||
healthCheck: async () => {
|
||||
const validator = new DatabaseValidator();
|
||||
const result = await validator.validateDatabase();
|
||||
return result.success;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Application Code Deployment",
|
||||
description: "Deploy new application code",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.deployApplicationCode();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Service Restart",
|
||||
description: "Restart application services",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
this.downtimeStart = Date.now();
|
||||
await this.restartServices();
|
||||
this.downtimeEnd = Date.now();
|
||||
|
||||
const downtime = this.downtimeEnd - this.downtimeStart;
|
||||
if (downtime > this.options.maxDowntime) {
|
||||
throw new Error(`Downtime exceeded maximum allowed: ${downtime}ms > ${this.options.maxDowntime}ms`);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "tRPC Activation",
|
||||
description: "Enable tRPC endpoints",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.activateTRPCEndpoints();
|
||||
},
|
||||
healthCheck: async () => {
|
||||
return await this.testTRPCEndpoints();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Batch Processing Activation",
|
||||
description: "Enable batch processing system",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.activateBatchProcessing();
|
||||
},
|
||||
healthCheck: async () => {
|
||||
return await this.testBatchProcessing();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Post-Deployment Validation",
|
||||
description: "Validate deployment success",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.runPostDeploymentValidation();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Progressive Rollout",
|
||||
description: "Gradually enable new features",
|
||||
critical: false,
|
||||
execute: async () => {
|
||||
if (this.options.enableProgressiveRollout) {
|
||||
await this.performProgressiveRollout();
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async runPreDeploymentChecks(): Promise<void> {
|
||||
migrationLogger.startStep("PRE_CHECKS", "Running pre-deployment validation");
|
||||
|
||||
const checker = new PreDeploymentChecker();
|
||||
const result = await checker.runAllChecks();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Pre-deployment checks failed with ${result.criticalFailures} critical failures`);
|
||||
}
|
||||
|
||||
if (result.warningCount > 0) {
|
||||
migrationLogger.warn("PRE_CHECKS", `Proceeding with ${result.warningCount} warnings`);
|
||||
}
|
||||
|
||||
migrationLogger.completeStep("PRE_CHECKS");
|
||||
}
|
||||
|
||||
private async createBackup(): Promise<string> {
|
||||
migrationLogger.startStep("BACKUP", "Creating database backup");
|
||||
|
||||
const backup = new DatabaseBackup();
|
||||
const result = await backup.createBackup();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Backup failed: ${result.error?.message}`);
|
||||
}
|
||||
|
||||
migrationLogger.completeStep("BACKUP");
|
||||
migrationLogger.info("BACKUP", "Backup created successfully", {
|
||||
path: result.backupPath,
|
||||
size: result.size,
|
||||
});
|
||||
|
||||
return result.backupPath;
|
||||
}
|
||||
|
||||
private async executePhase(phase: DeploymentPhase): Promise<void> {
|
||||
try {
|
||||
migrationLogger.startStep(phase.name.replace(/\s+/g, '_').toUpperCase(), phase.description);
|
||||
|
||||
if (this.options.dryRun) {
|
||||
migrationLogger.info("DRY_RUN", `Would execute: ${phase.name}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // Simulate execution time
|
||||
} else {
|
||||
await phase.execute();
|
||||
}
|
||||
|
||||
// Run health check if provided
|
||||
if (phase.healthCheck && !this.options.dryRun) {
|
||||
const healthy = await phase.healthCheck();
|
||||
if (!healthy) {
|
||||
throw new Error(`Health check failed for phase: ${phase.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
migrationLogger.completeStep(phase.name.replace(/\s+/g, '_').toUpperCase());
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.failStep(phase.name.replace(/\s+/g, '_').toUpperCase(), error as Error);
|
||||
|
||||
if (phase.critical) {
|
||||
throw error;
|
||||
} else {
|
||||
migrationLogger.warn("PHASE", `Non-critical phase failed: ${phase.name}`, { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runDatabaseMigrations(): Promise<void> {
|
||||
migrationLogger.info("DB_MIGRATION", "Applying database schema migrations");
|
||||
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
|
||||
// Run Prisma migrations
|
||||
execSync("npx prisma migrate deploy", {
|
||||
stdio: "pipe",
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
migrationLogger.info("DB_MIGRATION", "Database migrations completed successfully");
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Database migration failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async rollbackDatabaseMigrations(): Promise<void> {
|
||||
migrationLogger.warn("DB_ROLLBACK", "Rolling back database migrations");
|
||||
|
||||
try {
|
||||
// This would typically involve running specific rollback migrations
|
||||
// For now, we'll log the intent
|
||||
migrationLogger.warn("DB_ROLLBACK", "Database rollback would be performed here");
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Database rollback failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async deployApplicationCode(): Promise<void> {
|
||||
migrationLogger.info("CODE_DEPLOY", "Deploying application code");
|
||||
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
|
||||
// Build the application
|
||||
execSync("pnpm build", {
|
||||
stdio: "pipe",
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
migrationLogger.info("CODE_DEPLOY", "Application build completed successfully");
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Code deployment failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async restartServices(): Promise<void> {
|
||||
migrationLogger.info("SERVICE_RESTART", "Restarting application services");
|
||||
|
||||
// In a real deployment, this would restart the actual services
|
||||
// For development, we'll simulate the restart
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
migrationLogger.info("SERVICE_RESTART", "Services restarted successfully");
|
||||
}
|
||||
|
||||
private async activateTRPCEndpoints(): Promise<void> {
|
||||
migrationLogger.info("TRPC_ACTIVATION", "Activating tRPC endpoints");
|
||||
|
||||
// Set environment variable to enable tRPC
|
||||
process.env.TRPC_ENABLED = "true";
|
||||
|
||||
migrationLogger.info("TRPC_ACTIVATION", "tRPC endpoints activated");
|
||||
}
|
||||
|
||||
private async testTRPCEndpoints(): Promise<boolean> {
|
||||
try {
|
||||
migrationLogger.info("TRPC_TEST", "Testing tRPC endpoints");
|
||||
|
||||
// Test basic tRPC endpoint
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
const response = await fetch(`${baseUrl}/api/trpc/auth.getSession`);
|
||||
|
||||
return response.status === 200 || response.status === 401; // 401 is OK for auth endpoint
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("TRPC_TEST", "tRPC endpoint test failed", error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async activateBatchProcessing(): Promise<void> {
|
||||
migrationLogger.info("BATCH_ACTIVATION", "Activating batch processing system");
|
||||
|
||||
// Set environment variable to enable batch processing
|
||||
process.env.BATCH_PROCESSING_ENABLED = "true";
|
||||
|
||||
migrationLogger.info("BATCH_ACTIVATION", "Batch processing system activated");
|
||||
}
|
||||
|
||||
private async testBatchProcessing(): Promise<boolean> {
|
||||
try {
|
||||
migrationLogger.info("BATCH_TEST", "Testing batch processing system");
|
||||
|
||||
// Test that batch processing components can be imported
|
||||
const { BatchProcessor } = await import("../../lib/batchProcessor");
|
||||
return BatchProcessor !== undefined;
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("BATCH_TEST", "Batch processing test failed", error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runPostDeploymentValidation(): Promise<void> {
|
||||
migrationLogger.info("POST_VALIDATION", "Running post-deployment validation");
|
||||
|
||||
const healthChecker = new HealthChecker();
|
||||
const result = await healthChecker.runHealthChecks();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Post-deployment validation failed: ${result.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
migrationLogger.info("POST_VALIDATION", "Post-deployment validation passed");
|
||||
}
|
||||
|
||||
private async performProgressiveRollout(): Promise<void> {
|
||||
migrationLogger.info("PROGRESSIVE_ROLLOUT", "Starting progressive feature rollout");
|
||||
|
||||
// This would implement a gradual rollout strategy
|
||||
// For now, we'll just enable all features
|
||||
const rolloutSteps = [
|
||||
{ feature: "tRPC Authentication", percentage: 100 },
|
||||
{ feature: "tRPC Dashboard APIs", percentage: 100 },
|
||||
{ feature: "Batch Processing", percentage: 100 },
|
||||
];
|
||||
|
||||
for (const step of rolloutSteps) {
|
||||
migrationLogger.info("PROGRESSIVE_ROLLOUT", `Enabling ${step.feature} at ${step.percentage}%`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
migrationLogger.info("PROGRESSIVE_ROLLOUT", "Progressive rollout completed");
|
||||
}
|
||||
|
||||
private async performRollback(): Promise<void> {
|
||||
migrationLogger.warn("ROLLBACK", "Starting deployment rollback");
|
||||
|
||||
// Rollback executed phases in reverse order
|
||||
const rollbackPhases = this.phases.filter(p =>
|
||||
this.executedPhases.includes(p.name) && p.rollback
|
||||
).reverse();
|
||||
|
||||
for (const phase of rollbackPhases) {
|
||||
try {
|
||||
migrationLogger.info("ROLLBACK", `Rolling back: ${phase.name}`);
|
||||
|
||||
if (phase.rollback) {
|
||||
await phase.rollback();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("ROLLBACK", `Rollback failed for ${phase.name}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
migrationLogger.warn("ROLLBACK", "Rollback completed");
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const options: Partial<DeploymentOptions> = {};
|
||||
|
||||
// Parse command line arguments
|
||||
args.forEach(arg => {
|
||||
switch (arg) {
|
||||
case "--dry-run":
|
||||
options.dryRun = true;
|
||||
break;
|
||||
case "--skip-pre-checks":
|
||||
options.skipPreChecks = true;
|
||||
break;
|
||||
case "--skip-backup":
|
||||
options.skipBackup = true;
|
||||
break;
|
||||
case "--no-rollback":
|
||||
options.rollbackOnFailure = false;
|
||||
break;
|
||||
case "--no-progressive-rollout":
|
||||
options.enableProgressiveRollout = false;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const orchestrator = new DeploymentOrchestrator(options);
|
||||
|
||||
orchestrator.deploy()
|
||||
.then((result) => {
|
||||
console.log('\n=== DEPLOYMENT RESULTS ===');
|
||||
console.log(`Success: ${result.success ? '✅' : '❌'}`);
|
||||
console.log(`Total Duration: ${result.totalDuration}ms`);
|
||||
console.log(`Downtime: ${result.downtime}ms`);
|
||||
console.log(`Completed Phases: ${result.completedPhases.length}`);
|
||||
|
||||
if (result.backupPath) {
|
||||
console.log(`Backup Created: ${result.backupPath}`);
|
||||
}
|
||||
|
||||
if (result.failedPhase) {
|
||||
console.log(`Failed Phase: ${result.failedPhase}`);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Error: ${result.error.message}`);
|
||||
}
|
||||
|
||||
console.log('\nCompleted Phases:');
|
||||
result.completedPhases.forEach(phase => console.log(` ✅ ${phase}`));
|
||||
|
||||
if (result.success) {
|
||||
console.log('\n🎉 DEPLOYMENT SUCCESSFUL!');
|
||||
console.log('\nNext Steps:');
|
||||
console.log('1. Monitor application logs for any issues');
|
||||
console.log('2. Run post-deployment tests: pnpm migration:test');
|
||||
console.log('3. Verify new features are working correctly');
|
||||
} else {
|
||||
console.log('\n💥 DEPLOYMENT FAILED!');
|
||||
console.log('\nNext Steps:');
|
||||
console.log('1. Check logs for error details');
|
||||
console.log('2. Fix identified issues');
|
||||
console.log('3. Re-run deployment');
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Deployment orchestration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
659
scripts/migration/environment-migration.ts
Normal file
659
scripts/migration/environment-migration.ts
Normal file
@ -0,0 +1,659 @@
|
||||
/**
|
||||
* Environment Variable Migration Guide
|
||||
*
|
||||
* Handles migration of environment variables for the new tRPC and
|
||||
* batch processing architecture. Provides validation, transformation,
|
||||
* and documentation of required environment changes.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
|
||||
interface EnvironmentConfig {
|
||||
key: string;
|
||||
description: string;
|
||||
defaultValue?: string;
|
||||
required: boolean;
|
||||
newInVersion?: string;
|
||||
deprecated?: boolean;
|
||||
validationRegex?: string;
|
||||
example?: string;
|
||||
}
|
||||
|
||||
interface MigrationResult {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
added: string[];
|
||||
deprecated: string[];
|
||||
updated: string[];
|
||||
}
|
||||
|
||||
export class EnvironmentMigration {
|
||||
private readonly newEnvironmentVariables: EnvironmentConfig[] = [
|
||||
// tRPC Configuration
|
||||
{
|
||||
key: "TRPC_ENDPOINT_URL",
|
||||
description: "Base URL for tRPC API endpoints",
|
||||
defaultValue: "http://localhost:3000/api/trpc",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
example: "https://yourdomain.com/api/trpc"
|
||||
},
|
||||
{
|
||||
key: "TRPC_BATCH_TIMEOUT",
|
||||
description: "Timeout in milliseconds for tRPC batch requests",
|
||||
defaultValue: "30000",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^[0-9]+$"
|
||||
},
|
||||
{
|
||||
key: "TRPC_MAX_BATCH_SIZE",
|
||||
description: "Maximum number of requests in a single tRPC batch",
|
||||
defaultValue: "100",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^[0-9]+$"
|
||||
},
|
||||
|
||||
// Batch Processing Configuration
|
||||
{
|
||||
key: "BATCH_PROCESSING_ENABLED",
|
||||
description: "Enable OpenAI Batch API processing for cost reduction",
|
||||
defaultValue: "true",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^(true|false)$"
|
||||
},
|
||||
{
|
||||
key: "BATCH_CREATE_INTERVAL",
|
||||
description: "Cron expression for creating new batch requests",
|
||||
defaultValue: "*/5 * * * *",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
example: "*/5 * * * * (every 5 minutes)"
|
||||
},
|
||||
{
|
||||
key: "BATCH_STATUS_CHECK_INTERVAL",
|
||||
description: "Cron expression for checking batch status",
|
||||
defaultValue: "*/2 * * * *",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
example: "*/2 * * * * (every 2 minutes)"
|
||||
},
|
||||
{
|
||||
key: "BATCH_RESULT_PROCESSING_INTERVAL",
|
||||
description: "Cron expression for processing batch results",
|
||||
defaultValue: "*/1 * * * *",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
example: "*/1 * * * * (every minute)"
|
||||
},
|
||||
{
|
||||
key: "BATCH_MAX_REQUESTS",
|
||||
description: "Maximum number of requests per batch",
|
||||
defaultValue: "1000",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^[0-9]+$"
|
||||
},
|
||||
{
|
||||
key: "BATCH_TIMEOUT_HOURS",
|
||||
description: "Maximum hours to wait for batch completion",
|
||||
defaultValue: "24",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^[0-9]+$"
|
||||
},
|
||||
|
||||
// Migration Specific
|
||||
{
|
||||
key: "MIGRATION_MODE",
|
||||
description: "Migration mode: development, staging, or production",
|
||||
defaultValue: "development",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^(development|staging|production)$"
|
||||
},
|
||||
{
|
||||
key: "MIGRATION_BACKUP_ENABLED",
|
||||
description: "Enable automatic database backups during migration",
|
||||
defaultValue: "true",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^(true|false)$"
|
||||
},
|
||||
{
|
||||
key: "MIGRATION_ROLLBACK_ENABLED",
|
||||
description: "Enable rollback capabilities during migration",
|
||||
defaultValue: "true",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^(true|false)$"
|
||||
},
|
||||
|
||||
// Enhanced Security
|
||||
{
|
||||
key: "RATE_LIMIT_WINDOW_MS",
|
||||
description: "Rate limiting window in milliseconds",
|
||||
defaultValue: "900000",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^[0-9]+$",
|
||||
example: "900000 (15 minutes)"
|
||||
},
|
||||
{
|
||||
key: "RATE_LIMIT_MAX_REQUESTS",
|
||||
description: "Maximum requests per rate limit window",
|
||||
defaultValue: "100",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^[0-9]+$"
|
||||
},
|
||||
|
||||
// Performance Monitoring
|
||||
{
|
||||
key: "PERFORMANCE_MONITORING_ENABLED",
|
||||
description: "Enable performance monitoring and metrics collection",
|
||||
defaultValue: "true",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^(true|false)$"
|
||||
},
|
||||
{
|
||||
key: "METRICS_COLLECTION_INTERVAL",
|
||||
description: "Interval for collecting performance metrics (in seconds)",
|
||||
defaultValue: "60",
|
||||
required: false,
|
||||
newInVersion: "2.0.0",
|
||||
validationRegex: "^[0-9]+$"
|
||||
}
|
||||
];
|
||||
|
||||
private readonly deprecatedVariables: string[] = [
|
||||
// Add any variables that are being deprecated
|
||||
// "OLD_API_ENDPOINT",
|
||||
// "LEGACY_PROCESSING_MODE"
|
||||
];
|
||||
|
||||
/**
|
||||
* Run complete environment migration
|
||||
*/
|
||||
async migrateEnvironment(): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
success: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
added: [],
|
||||
deprecated: [],
|
||||
updated: []
|
||||
};
|
||||
|
||||
try {
|
||||
migrationLogger.startStep("ENVIRONMENT_MIGRATION", "Migrating environment configuration");
|
||||
|
||||
// Read current environment
|
||||
const currentEnv = this.readCurrentEnvironment();
|
||||
|
||||
// Validate existing environment
|
||||
await this.validateExistingEnvironment(currentEnv, result);
|
||||
|
||||
// Add new environment variables
|
||||
await this.addNewEnvironmentVariables(currentEnv, result);
|
||||
|
||||
// Check for deprecated variables
|
||||
await this.checkDeprecatedVariables(currentEnv, result);
|
||||
|
||||
// Create migration guide
|
||||
await this.createMigrationGuide(result);
|
||||
|
||||
// Create example environment file
|
||||
await this.createExampleEnvironmentFile();
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.completeStep("ENVIRONMENT_MIGRATION");
|
||||
} else {
|
||||
migrationLogger.failStep("ENVIRONMENT_MIGRATION", new Error(`Migration failed with ${result.errors.length} errors`));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.errors.push(`Environment migration failed: ${(error as Error).message}`);
|
||||
migrationLogger.error("ENVIRONMENT_MIGRATION", "Critical migration error", error as Error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private readCurrentEnvironment(): Record<string, string> {
|
||||
const envFiles = [".env.local", ".env.production", ".env"];
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
// Merge environment from multiple sources
|
||||
envFiles.forEach(filename => {
|
||||
const filepath = join(process.cwd(), filename);
|
||||
if (existsSync(filepath)) {
|
||||
try {
|
||||
const content = readFileSync(filepath, "utf8");
|
||||
const parsed = this.parseEnvFile(content);
|
||||
Object.assign(env, parsed);
|
||||
migrationLogger.debug("ENV_READER", `Loaded environment from ${filename}`, { variables: Object.keys(parsed).length });
|
||||
} catch (error) {
|
||||
migrationLogger.warn("ENV_READER", `Failed to read ${filename}`, { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Include process environment
|
||||
Object.assign(env, process.env);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
const lines = content.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const [key, ...valueParts] = trimmed.split("=");
|
||||
if (key && valueParts.length > 0) {
|
||||
const value = valueParts.join("=").replace(/^["']|["']$/g, "");
|
||||
env[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
private async validateExistingEnvironment(
|
||||
currentEnv: Record<string, string>,
|
||||
result: MigrationResult
|
||||
): Promise<void> {
|
||||
migrationLogger.info("ENV_VALIDATION", "Validating existing environment variables");
|
||||
|
||||
// Check required existing variables
|
||||
const requiredExisting = [
|
||||
"DATABASE_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"OPENAI_API_KEY"
|
||||
];
|
||||
|
||||
for (const key of requiredExisting) {
|
||||
if (!currentEnv[key]) {
|
||||
result.errors.push(`Required environment variable missing: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate new variables that might already exist
|
||||
for (const config of this.newEnvironmentVariables) {
|
||||
const value = currentEnv[config.key];
|
||||
if (value && config.validationRegex) {
|
||||
const regex = new RegExp(config.validationRegex);
|
||||
if (!regex.test(value)) {
|
||||
result.warnings.push(`Invalid format for ${config.key}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async addNewEnvironmentVariables(
|
||||
currentEnv: Record<string, string>,
|
||||
result: MigrationResult
|
||||
): Promise<void> {
|
||||
migrationLogger.info("ENV_ADDITION", "Adding new environment variables");
|
||||
|
||||
const newEnvContent: string[] = [];
|
||||
newEnvContent.push("# New environment variables for tRPC and Batch Processing");
|
||||
newEnvContent.push("# Added during migration to version 2.0.0");
|
||||
newEnvContent.push("");
|
||||
|
||||
let addedCount = 0;
|
||||
|
||||
// Group variables by category
|
||||
const categories = {
|
||||
"tRPC Configuration": this.newEnvironmentVariables.filter(v => v.key.startsWith("TRPC_")),
|
||||
"Batch Processing": this.newEnvironmentVariables.filter(v => v.key.startsWith("BATCH_")),
|
||||
"Migration Settings": this.newEnvironmentVariables.filter(v => v.key.startsWith("MIGRATION_")),
|
||||
"Security & Performance": this.newEnvironmentVariables.filter(v =>
|
||||
v.key.startsWith("RATE_LIMIT_") || v.key.startsWith("PERFORMANCE_") || v.key.startsWith("METRICS_")
|
||||
)
|
||||
};
|
||||
|
||||
for (const [category, variables] of Object.entries(categories)) {
|
||||
if (variables.length === 0) continue;
|
||||
|
||||
newEnvContent.push(`# ${category}`);
|
||||
|
||||
for (const config of variables) {
|
||||
if (!currentEnv[config.key]) {
|
||||
newEnvContent.push(`# ${config.description}`);
|
||||
if (config.example) {
|
||||
newEnvContent.push(`# Example: ${config.example}`);
|
||||
}
|
||||
const value = config.defaultValue || "";
|
||||
newEnvContent.push(`${config.key}=${value}`);
|
||||
newEnvContent.push("");
|
||||
|
||||
result.added.push(config.key);
|
||||
addedCount++;
|
||||
} else {
|
||||
result.updated.push(config.key);
|
||||
}
|
||||
}
|
||||
|
||||
newEnvContent.push("");
|
||||
}
|
||||
|
||||
// Write new environment template
|
||||
if (addedCount > 0) {
|
||||
const templatePath = join(process.cwd(), ".env.migration.template");
|
||||
writeFileSync(templatePath, newEnvContent.join("\n"));
|
||||
migrationLogger.info("ENV_ADDITION", `Created environment template with ${addedCount} new variables`, {
|
||||
templatePath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async checkDeprecatedVariables(
|
||||
currentEnv: Record<string, string>,
|
||||
result: MigrationResult
|
||||
): Promise<void> {
|
||||
migrationLogger.info("ENV_DEPRECATION", "Checking for deprecated environment variables");
|
||||
|
||||
for (const deprecatedKey of this.deprecatedVariables) {
|
||||
if (currentEnv[deprecatedKey]) {
|
||||
result.deprecated.push(deprecatedKey);
|
||||
result.warnings.push(`Deprecated environment variable found: ${deprecatedKey}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createMigrationGuide(result: MigrationResult): Promise<void> {
|
||||
const guide = `
|
||||
# Environment Migration Guide
|
||||
|
||||
This guide helps you migrate your environment configuration for the new tRPC and Batch Processing architecture.
|
||||
|
||||
## Migration Summary
|
||||
|
||||
- **New Variables Added**: ${result.added.length}
|
||||
- **Variables Updated**: ${result.updated.length}
|
||||
- **Variables Deprecated**: ${result.deprecated.length}
|
||||
- **Errors Found**: ${result.errors.length}
|
||||
- **Warnings**: ${result.warnings.length}
|
||||
|
||||
## Required Actions
|
||||
|
||||
### 1. Add New Environment Variables
|
||||
|
||||
${result.added.length > 0 ? `
|
||||
The following new environment variables need to be added to your \`.env.local\` file:
|
||||
|
||||
${result.added.map(key => {
|
||||
const config = this.newEnvironmentVariables.find(v => v.key === key);
|
||||
return `
|
||||
#### ${key}
|
||||
- **Description**: ${config?.description}
|
||||
- **Default**: ${config?.defaultValue || 'Not set'}
|
||||
- **Required**: ${config?.required ? 'Yes' : 'No'}
|
||||
${config?.example ? `- **Example**: ${config.example}` : ''}
|
||||
`;
|
||||
}).join('')}
|
||||
` : 'No new environment variables need to be added.'}
|
||||
|
||||
### 2. Update Existing Variables
|
||||
|
||||
${result.updated.length > 0 ? `
|
||||
The following variables already exist but may need review:
|
||||
|
||||
${result.updated.map(key => `- ${key}`).join('\n')}
|
||||
` : 'No existing variables need updates.'}
|
||||
|
||||
### 3. Handle Deprecated Variables
|
||||
|
||||
${result.deprecated.length > 0 ? `
|
||||
The following variables are deprecated and should be removed:
|
||||
|
||||
${result.deprecated.map(key => `- ${key}`).join('\n')}
|
||||
` : 'No deprecated variables found.'}
|
||||
|
||||
## Errors and Warnings
|
||||
|
||||
${result.errors.length > 0 ? `
|
||||
### Errors (Must Fix)
|
||||
${result.errors.map(error => `- ${error}`).join('\n')}
|
||||
` : ''}
|
||||
|
||||
${result.warnings.length > 0 ? `
|
||||
### Warnings (Recommended Fixes)
|
||||
${result.warnings.map(warning => `- ${warning}`).join('\n')}
|
||||
` : ''}
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Copy the new environment variables from \`.env.migration.template\` to your \`.env.local\` file
|
||||
2. Update any existing variables that need configuration changes
|
||||
3. Remove deprecated variables
|
||||
4. Run the environment validation: \`pnpm migration:validate-env\`
|
||||
5. Test the application with new configuration
|
||||
|
||||
## Environment Templates
|
||||
|
||||
- **Development**: \`.env.migration.template\`
|
||||
- **Production**: Update your production environment with the same variables
|
||||
- **Staging**: Ensure staging environment matches production configuration
|
||||
|
||||
## Verification
|
||||
|
||||
After updating your environment:
|
||||
|
||||
\`\`\`bash
|
||||
# Validate environment configuration
|
||||
pnpm migration:validate-env
|
||||
|
||||
# Test tRPC endpoints
|
||||
pnpm migration:test-trpc
|
||||
|
||||
# Test batch processing
|
||||
pnpm migration:test-batch
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const guidePath = join(process.cwd(), "ENVIRONMENT_MIGRATION_GUIDE.md");
|
||||
writeFileSync(guidePath, guide);
|
||||
|
||||
migrationLogger.info("MIGRATION_GUIDE", "Created environment migration guide", { guidePath });
|
||||
}
|
||||
|
||||
private async createExampleEnvironmentFile(): Promise<void> {
|
||||
const example = `# LiveDash Node - Environment Configuration
|
||||
# Copy this file to .env.local and update the values
|
||||
|
||||
# =============================================================================
|
||||
# CORE CONFIGURATION (Required)
|
||||
# =============================================================================
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/livedash"
|
||||
DATABASE_URL_DIRECT="postgresql://username:password@localhost:5432/livedash"
|
||||
|
||||
# Authentication
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="your-secret-key-here"
|
||||
|
||||
# OpenAI API
|
||||
OPENAI_API_KEY="your-openai-api-key"
|
||||
OPENAI_MOCK_MODE="false"
|
||||
|
||||
# =============================================================================
|
||||
# SCHEDULER CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
SCHEDULER_ENABLED="true"
|
||||
CSV_IMPORT_INTERVAL="*/15 * * * *"
|
||||
IMPORT_PROCESSING_INTERVAL="*/5 * * * *"
|
||||
IMPORT_PROCESSING_BATCH_SIZE="50"
|
||||
SESSION_PROCESSING_INTERVAL="0 * * * *"
|
||||
SESSION_PROCESSING_BATCH_SIZE="0"
|
||||
SESSION_PROCESSING_CONCURRENCY="5"
|
||||
|
||||
# =============================================================================
|
||||
# tRPC CONFIGURATION (New in v2.0.0)
|
||||
# =============================================================================
|
||||
|
||||
TRPC_ENDPOINT_URL="http://localhost:3000/api/trpc"
|
||||
TRPC_BATCH_TIMEOUT="30000"
|
||||
TRPC_MAX_BATCH_SIZE="100"
|
||||
|
||||
# =============================================================================
|
||||
# BATCH PROCESSING CONFIGURATION (New in v2.0.0)
|
||||
# =============================================================================
|
||||
|
||||
BATCH_PROCESSING_ENABLED="true"
|
||||
BATCH_CREATE_INTERVAL="*/5 * * * *"
|
||||
BATCH_STATUS_CHECK_INTERVAL="*/2 * * * *"
|
||||
BATCH_RESULT_PROCESSING_INTERVAL="*/1 * * * *"
|
||||
BATCH_MAX_REQUESTS="1000"
|
||||
BATCH_TIMEOUT_HOURS="24"
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY & PERFORMANCE (New in v2.0.0)
|
||||
# =============================================================================
|
||||
|
||||
RATE_LIMIT_WINDOW_MS="900000"
|
||||
RATE_LIMIT_MAX_REQUESTS="100"
|
||||
PERFORMANCE_MONITORING_ENABLED="true"
|
||||
METRICS_COLLECTION_INTERVAL="60"
|
||||
|
||||
# =============================================================================
|
||||
# MIGRATION SETTINGS (Temporary)
|
||||
# =============================================================================
|
||||
|
||||
MIGRATION_MODE="development"
|
||||
MIGRATION_BACKUP_ENABLED="true"
|
||||
MIGRATION_ROLLBACK_ENABLED="true"
|
||||
|
||||
# =============================================================================
|
||||
# DATABASE CONNECTION POOLING
|
||||
# =============================================================================
|
||||
|
||||
DATABASE_CONNECTION_LIMIT="20"
|
||||
DATABASE_POOL_TIMEOUT="10"
|
||||
|
||||
# =============================================================================
|
||||
# DEVELOPMENT SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
NODE_ENV="development"
|
||||
PORT="3000"
|
||||
`;
|
||||
|
||||
const examplePath = join(process.cwd(), ".env.example");
|
||||
writeFileSync(examplePath, example);
|
||||
|
||||
migrationLogger.info("EXAMPLE_ENV", "Created example environment file", { examplePath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate current environment configuration
|
||||
*/
|
||||
async validateEnvironmentConfiguration(): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
success: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
added: [],
|
||||
deprecated: [],
|
||||
updated: []
|
||||
};
|
||||
|
||||
const currentEnv = this.readCurrentEnvironment();
|
||||
|
||||
// Validate all new variables
|
||||
for (const config of this.newEnvironmentVariables) {
|
||||
const value = currentEnv[config.key];
|
||||
|
||||
if (config.required && !value) {
|
||||
result.errors.push(`Required environment variable missing: ${config.key}`);
|
||||
}
|
||||
|
||||
if (value && config.validationRegex) {
|
||||
const regex = new RegExp(config.validationRegex);
|
||||
if (!regex.test(value)) {
|
||||
result.errors.push(`Invalid format for ${config.key}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const migration = new EnvironmentMigration();
|
||||
|
||||
const command = process.argv[2];
|
||||
|
||||
if (command === "validate") {
|
||||
migration.validateEnvironmentConfiguration()
|
||||
.then((result) => {
|
||||
console.log('\n=== ENVIRONMENT VALIDATION RESULTS ===');
|
||||
console.log(`Success: ${result.success ? '✅' : '❌'}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log('\n❌ ERRORS:');
|
||||
result.errors.forEach(error => console.log(` - ${error}`));
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('\n⚠️ WARNINGS:');
|
||||
result.warnings.forEach(warning => console.log(` - ${warning}`));
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Validation failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
migration.migrateEnvironment()
|
||||
.then((result) => {
|
||||
console.log('\n=== ENVIRONMENT MIGRATION RESULTS ===');
|
||||
console.log(`Success: ${result.success ? '✅' : '❌'}`);
|
||||
console.log(`Added: ${result.added.length} variables`);
|
||||
console.log(`Updated: ${result.updated.length} variables`);
|
||||
console.log(`Deprecated: ${result.deprecated.length} variables`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log('\n❌ ERRORS:');
|
||||
result.errors.forEach(error => console.log(` - ${error}`));
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('\n⚠️ WARNINGS:');
|
||||
result.warnings.forEach(warning => console.log(` - ${warning}`));
|
||||
}
|
||||
|
||||
console.log('\n📋 Next Steps:');
|
||||
console.log('1. Review ENVIRONMENT_MIGRATION_GUIDE.md');
|
||||
console.log('2. Update your .env.local file with new variables');
|
||||
console.log('3. Run: pnpm migration:validate-env');
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
665
scripts/migration/health-checks.ts
Normal file
665
scripts/migration/health-checks.ts
Normal file
@ -0,0 +1,665 @@
|
||||
/**
|
||||
* Comprehensive Health Check System
|
||||
*
|
||||
* Validates that the deployed tRPC and batch processing architecture
|
||||
* is working correctly and all components are healthy.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
|
||||
interface HealthCheckResult {
|
||||
name: string;
|
||||
success: boolean;
|
||||
duration: number;
|
||||
details?: Record<string, unknown>;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface SystemHealthResult {
|
||||
success: boolean;
|
||||
checks: HealthCheckResult[];
|
||||
totalDuration: number;
|
||||
failedChecks: number;
|
||||
score: number; // 0-100
|
||||
}
|
||||
|
||||
export class HealthChecker {
|
||||
private prisma: PrismaClient;
|
||||
|
||||
constructor() {
|
||||
this.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comprehensive health checks
|
||||
*/
|
||||
async runHealthChecks(): Promise<SystemHealthResult> {
|
||||
const startTime = Date.now();
|
||||
const checks: HealthCheckResult[] = [];
|
||||
|
||||
try {
|
||||
migrationLogger.startStep("HEALTH_CHECKS", "Running comprehensive health checks");
|
||||
|
||||
// Define all health checks
|
||||
const healthChecks = [
|
||||
{ name: "Database Connection", fn: () => this.checkDatabaseConnection() },
|
||||
{ name: "Database Schema", fn: () => this.checkDatabaseSchema() },
|
||||
{ name: "tRPC Endpoints", fn: () => this.checkTRPCEndpoints() },
|
||||
{ name: "Batch Processing System", fn: () => this.checkBatchProcessingSystem() },
|
||||
{ name: "OpenAI API Access", fn: () => this.checkOpenAIAccess() },
|
||||
{ name: "Environment Configuration", fn: () => this.checkEnvironmentConfiguration() },
|
||||
{ name: "File System Access", fn: () => this.checkFileSystemAccess() },
|
||||
{ name: "Memory Usage", fn: () => this.checkMemoryUsage() },
|
||||
{ name: "CPU Usage", fn: () => this.checkCPUUsage() },
|
||||
{ name: "Application Performance", fn: () => this.checkApplicationPerformance() },
|
||||
{ name: "Security Configuration", fn: () => this.checkSecurityConfiguration() },
|
||||
{ name: "Logging System", fn: () => this.checkLoggingSystem() },
|
||||
];
|
||||
|
||||
// Run all checks
|
||||
for (const check of healthChecks) {
|
||||
const result = await this.runSingleHealthCheck(check.name, check.fn);
|
||||
checks.push(result);
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const failedChecks = checks.filter(c => !c.success).length;
|
||||
const score = Math.round(((checks.length - failedChecks) / checks.length) * 100);
|
||||
|
||||
const result: SystemHealthResult = {
|
||||
success: failedChecks === 0,
|
||||
checks,
|
||||
totalDuration,
|
||||
failedChecks,
|
||||
score,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.completeStep("HEALTH_CHECKS");
|
||||
} else {
|
||||
migrationLogger.failStep("HEALTH_CHECKS", new Error(`${failedChecks} health checks failed`));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("HEALTH_CHECKS", "Health check system failed", error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
await this.prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async runSingleHealthCheck(
|
||||
name: string,
|
||||
checkFn: () => Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }>
|
||||
): Promise<HealthCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.debug("HEALTH_CHECK", `Running: ${name}`);
|
||||
|
||||
const result = await checkFn();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const healthResult: HealthCheckResult = {
|
||||
name,
|
||||
success: result.success,
|
||||
duration,
|
||||
details: result.details,
|
||||
error: result.error,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.debug("HEALTH_CHECK", `✅ ${name} passed`, { duration, details: result.details });
|
||||
} else {
|
||||
migrationLogger.warn("HEALTH_CHECK", `❌ ${name} failed`, { duration, error: result.error?.message });
|
||||
}
|
||||
|
||||
return healthResult;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
migrationLogger.error("HEALTH_CHECK", `💥 ${name} crashed`, error as Error, { duration });
|
||||
|
||||
return {
|
||||
name,
|
||||
success: false,
|
||||
duration,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkDatabaseConnection(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
const queryTime = Date.now() - startTime;
|
||||
|
||||
// Test multiple connections
|
||||
const connectionTests = await Promise.all([
|
||||
this.prisma.$queryRaw`SELECT 1`,
|
||||
this.prisma.$queryRaw`SELECT 1`,
|
||||
this.prisma.$queryRaw`SELECT 1`,
|
||||
]);
|
||||
|
||||
return {
|
||||
success: connectionTests.length === 3,
|
||||
details: {
|
||||
queryTime,
|
||||
connectionPoolTest: "passed"
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkDatabaseSchema(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Check critical tables
|
||||
const tableChecks = await Promise.allSettled([
|
||||
this.prisma.company.findFirst(),
|
||||
this.prisma.user.findFirst(),
|
||||
this.prisma.session.findFirst(),
|
||||
this.prisma.aIBatchRequest.findFirst(),
|
||||
this.prisma.aIProcessingRequest.findFirst(),
|
||||
]);
|
||||
|
||||
const failedTables = tableChecks.filter(result => result.status === 'rejected').length;
|
||||
|
||||
// Check for critical indexes
|
||||
const indexCheck = await this.prisma.$queryRaw<{count: string}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM pg_indexes
|
||||
WHERE tablename IN ('Session', 'AIProcessingRequest', 'AIBatchRequest')
|
||||
`;
|
||||
|
||||
const indexCount = parseInt(indexCheck[0]?.count || '0');
|
||||
|
||||
return {
|
||||
success: failedTables === 0,
|
||||
details: {
|
||||
accessibleTables: tableChecks.length - failedTables,
|
||||
totalTables: tableChecks.length,
|
||||
indexes: indexCount
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkTRPCEndpoints(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
|
||||
// Test tRPC endpoint accessibility
|
||||
const endpoints = [
|
||||
`${baseUrl}/api/trpc/auth.getSession`,
|
||||
`${baseUrl}/api/trpc/dashboard.getMetrics`,
|
||||
];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
endpoints.map(async (url) => {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ json: null }),
|
||||
});
|
||||
return { url, status: response.status };
|
||||
})
|
||||
);
|
||||
|
||||
const successfulEndpoints = results.filter(
|
||||
result => result.status === 'fulfilled' &&
|
||||
(result.value.status === 200 || result.value.status === 401 || result.value.status === 403)
|
||||
).length;
|
||||
|
||||
return {
|
||||
success: successfulEndpoints > 0,
|
||||
details: {
|
||||
testedEndpoints: endpoints.length,
|
||||
successfulEndpoints,
|
||||
endpoints: results.map(r =>
|
||||
r.status === 'fulfilled' ? r.value : { error: r.reason.message }
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkBatchProcessingSystem(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Check batch processing components
|
||||
const batchEnabled = process.env.BATCH_PROCESSING_ENABLED === "true";
|
||||
|
||||
// Test database components
|
||||
const batchRequestsCount = await this.prisma.aIBatchRequest.count();
|
||||
const processingRequestsCount = await this.prisma.aIProcessingRequest.count();
|
||||
|
||||
// Check if batch processor can be imported
|
||||
let batchProcessorAvailable = false;
|
||||
try {
|
||||
await import("../../lib/batchProcessor");
|
||||
batchProcessorAvailable = true;
|
||||
} catch {
|
||||
// Batch processor not available
|
||||
}
|
||||
|
||||
// Check batch status distribution
|
||||
const batchStatuses = await this.prisma.aIBatchRequest.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
});
|
||||
|
||||
return {
|
||||
success: batchEnabled && batchProcessorAvailable,
|
||||
details: {
|
||||
enabled: batchEnabled,
|
||||
processorAvailable: batchProcessorAvailable,
|
||||
batchRequests: batchRequestsCount,
|
||||
processingRequests: processingRequestsCount,
|
||||
statusDistribution: Object.fromEntries(
|
||||
batchStatuses.map(s => [s.status, s._count.status])
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkOpenAIAccess(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const mockMode = process.env.OPENAI_MOCK_MODE === "true";
|
||||
|
||||
if (mockMode) {
|
||||
return {
|
||||
success: true,
|
||||
details: { mode: "mock", available: true }
|
||||
};
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error("OPENAI_API_KEY not configured")
|
||||
};
|
||||
}
|
||||
|
||||
// Test API with a simple request
|
||||
const response = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const responseTime = Date.now();
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
details: {
|
||||
mode: "live",
|
||||
available: response.ok,
|
||||
status: response.status,
|
||||
responseTime: responseTime
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkEnvironmentConfiguration(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const requiredVars = [
|
||||
"DATABASE_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXTAUTH_URL"
|
||||
];
|
||||
|
||||
const missingVars = requiredVars.filter(varName => !process.env[varName]);
|
||||
|
||||
const newVars = [
|
||||
"BATCH_PROCESSING_ENABLED",
|
||||
"TRPC_ENDPOINT_URL",
|
||||
"BATCH_CREATE_INTERVAL"
|
||||
];
|
||||
|
||||
const missingNewVars = newVars.filter(varName => !process.env[varName]);
|
||||
|
||||
return {
|
||||
success: missingVars.length === 0,
|
||||
details: {
|
||||
requiredVarsPresent: requiredVars.length - missingVars.length,
|
||||
totalRequiredVars: requiredVars.length,
|
||||
newVarsPresent: newVars.length - missingNewVars.length,
|
||||
totalNewVars: newVars.length,
|
||||
missingRequired: missingVars,
|
||||
missingNew: missingNewVars
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkFileSystemAccess(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
|
||||
// Test write access to logs directory
|
||||
const logsDir = path.join(process.cwd(), "logs");
|
||||
const testFile = path.join(logsDir, "health-check.tmp");
|
||||
|
||||
try {
|
||||
await fs.mkdir(logsDir, { recursive: true });
|
||||
await fs.writeFile(testFile, "health check");
|
||||
await fs.unlink(testFile);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error(`Cannot write to logs directory: ${(error as Error).message}`)
|
||||
};
|
||||
}
|
||||
|
||||
// Test read access to package.json
|
||||
try {
|
||||
await fs.access(path.join(process.cwd(), "package.json"));
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: new Error("Cannot access package.json")
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
details: {
|
||||
logsWritable: true,
|
||||
packageJsonReadable: true
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkMemoryUsage(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
const usedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
||||
const totalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
|
||||
const externalMB = Math.round(memUsage.external / 1024 / 1024);
|
||||
|
||||
// Consider memory healthy if heap usage is under 80% of total
|
||||
const usagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
||||
const healthy = usagePercent < 80;
|
||||
|
||||
return {
|
||||
success: healthy,
|
||||
details: {
|
||||
heapUsed: usedMB,
|
||||
heapTotal: totalMB,
|
||||
external: externalMB,
|
||||
usagePercent: Math.round(usagePercent)
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCPUUsage(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const cpuUsage = process.cpuUsage();
|
||||
const userTime = cpuUsage.user / 1000; // Convert to milliseconds
|
||||
const systemTime = cpuUsage.system / 1000;
|
||||
|
||||
// Simple CPU health check - process should be responsive
|
||||
const startTime = Date.now();
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: responseTime < 50, // Should respond within 50ms
|
||||
details: {
|
||||
userTime,
|
||||
systemTime,
|
||||
responseTime
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkApplicationPerformance(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Test database query performance
|
||||
const dbStartTime = Date.now();
|
||||
await this.prisma.company.findFirst();
|
||||
const dbQueryTime = Date.now() - dbStartTime;
|
||||
|
||||
// Test complex query performance
|
||||
const complexStartTime = Date.now();
|
||||
await this.prisma.session.findMany({
|
||||
include: {
|
||||
messages: { take: 5 },
|
||||
processingStatus: true,
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
const complexQueryTime = Date.now() - complexStartTime;
|
||||
|
||||
return {
|
||||
success: dbQueryTime < 100 && complexQueryTime < 500,
|
||||
details: {
|
||||
simpleQueryTime: dbQueryTime,
|
||||
complexQueryTime: complexQueryTime,
|
||||
performanceGood: dbQueryTime < 100 && complexQueryTime < 500
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkSecurityConfiguration(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
const securityIssues: string[] = [];
|
||||
|
||||
// Check NEXTAUTH_SECRET strength
|
||||
const secret = process.env.NEXTAUTH_SECRET;
|
||||
if (!secret || secret.length < 32) {
|
||||
securityIssues.push("Weak NEXTAUTH_SECRET");
|
||||
}
|
||||
|
||||
// Check if using secure URLs in production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
const url = process.env.NEXTAUTH_URL;
|
||||
if (url && !url.startsWith("https://")) {
|
||||
securityIssues.push("Non-HTTPS URL in production");
|
||||
}
|
||||
}
|
||||
|
||||
// Check rate limiting configuration
|
||||
if (!process.env.RATE_LIMIT_WINDOW_MS) {
|
||||
securityIssues.push("Rate limiting not configured");
|
||||
}
|
||||
|
||||
return {
|
||||
success: securityIssues.length === 0,
|
||||
details: {
|
||||
securityIssues,
|
||||
hasSecret: !!secret,
|
||||
rateLimitConfigured: !!process.env.RATE_LIMIT_WINDOW_MS
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkLoggingSystem(): Promise<{ success: boolean; details?: Record<string, unknown>; error?: Error }> {
|
||||
try {
|
||||
// Test if logging works
|
||||
const testMessage = `Health check test ${Date.now()}`;
|
||||
migrationLogger.debug("HEALTH_TEST", testMessage);
|
||||
|
||||
// Check if log directory exists and is writable
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
|
||||
const logsDir = path.join(process.cwd(), "logs");
|
||||
const logsDirExists = fs.existsSync(logsDir);
|
||||
|
||||
return {
|
||||
success: logsDirExists,
|
||||
details: {
|
||||
logsDirExists,
|
||||
testMessageLogged: true
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error as Error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate health report
|
||||
*/
|
||||
generateHealthReport(result: SystemHealthResult): string {
|
||||
const report = `
|
||||
# System Health Report
|
||||
|
||||
**Overall Status**: ${result.success ? '✅ Healthy' : '❌ Unhealthy'}
|
||||
**Health Score**: ${result.score}/100
|
||||
**Total Duration**: ${result.totalDuration}ms
|
||||
**Failed Checks**: ${result.failedChecks}/${result.checks.length}
|
||||
|
||||
## Health Check Results
|
||||
|
||||
${result.checks.map(check => `
|
||||
### ${check.name}
|
||||
- **Status**: ${check.success ? '✅ Pass' : '❌ Fail'}
|
||||
- **Duration**: ${check.duration}ms
|
||||
${check.details ? `- **Details**: ${JSON.stringify(check.details, null, 2)}` : ''}
|
||||
${check.error ? `- **Error**: ${check.error.message}` : ''}
|
||||
`).join('')}
|
||||
|
||||
## Summary
|
||||
|
||||
${result.success ?
|
||||
'🎉 All health checks passed! The system is operating normally.' :
|
||||
`⚠️ ${result.failedChecks} health check(s) failed. Please review and address the issues above.`
|
||||
}
|
||||
|
||||
---
|
||||
*Generated at ${new Date().toISOString()}*
|
||||
`;
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const healthChecker = new HealthChecker();
|
||||
|
||||
const generateReport = process.argv.includes("--report");
|
||||
|
||||
healthChecker.runHealthChecks()
|
||||
.then((result) => {
|
||||
console.log('\n=== SYSTEM HEALTH CHECK RESULTS ===');
|
||||
console.log(`Overall Health: ${result.success ? '✅ Healthy' : '❌ Unhealthy'}`);
|
||||
console.log(`Health Score: ${result.score}/100`);
|
||||
console.log(`Total Duration: ${result.totalDuration}ms`);
|
||||
console.log(`Failed Checks: ${result.failedChecks}/${result.checks.length}`);
|
||||
|
||||
console.log('\n=== INDIVIDUAL CHECKS ===');
|
||||
for (const check of result.checks) {
|
||||
const status = check.success ? '✅' : '❌';
|
||||
console.log(`${status} ${check.name} (${check.duration}ms)`);
|
||||
|
||||
if (check.details) {
|
||||
console.log(` Details:`, check.details);
|
||||
}
|
||||
|
||||
if (check.error) {
|
||||
console.log(` Error: ${check.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (generateReport) {
|
||||
const report = healthChecker.generateHealthReport(result);
|
||||
const fs = require("node:fs");
|
||||
const reportPath = `health-report-${Date.now()}.md`;
|
||||
fs.writeFileSync(reportPath, report);
|
||||
console.log(`\n📋 Health report saved to: ${reportPath}`);
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Health checks failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
233
scripts/migration/migration-logger.ts
Normal file
233
scripts/migration/migration-logger.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Migration Logging Utilities
|
||||
*
|
||||
* Provides comprehensive logging functionality for migration operations
|
||||
* with different log levels, structured output, and file persistence.
|
||||
*/
|
||||
|
||||
import { writeFileSync, appendFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
CRITICAL = 4,
|
||||
}
|
||||
|
||||
export interface MigrationLogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
category: string;
|
||||
message: string;
|
||||
data?: Record<string, unknown>;
|
||||
duration?: number;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class MigrationLogger {
|
||||
private logFile: string;
|
||||
private startTime: number;
|
||||
private minLogLevel: LogLevel;
|
||||
|
||||
constructor(
|
||||
logFile: string = "migration.log",
|
||||
minLogLevel: LogLevel = LogLevel.INFO
|
||||
) {
|
||||
this.logFile = join(process.cwd(), "logs", logFile);
|
||||
this.minLogLevel = minLogLevel;
|
||||
this.startTime = Date.now();
|
||||
this.ensureLogDirectory();
|
||||
this.initializeLog();
|
||||
}
|
||||
|
||||
private ensureLogDirectory(): void {
|
||||
const logDir = join(process.cwd(), "logs");
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private initializeLog(): void {
|
||||
const header = `
|
||||
=================================================================
|
||||
MIGRATION LOG SESSION STARTED
|
||||
=================================================================
|
||||
Time: ${new Date().toISOString()}
|
||||
Process ID: ${process.pid}
|
||||
Node Version: ${process.version}
|
||||
Platform: ${process.platform}
|
||||
Working Directory: ${process.cwd()}
|
||||
=================================================================
|
||||
|
||||
`;
|
||||
writeFileSync(this.logFile, header);
|
||||
}
|
||||
|
||||
private createLogEntry(
|
||||
level: LogLevel,
|
||||
category: string,
|
||||
message: string,
|
||||
data?: Record<string, unknown>,
|
||||
error?: Error
|
||||
): MigrationLogEntry {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
category,
|
||||
message,
|
||||
data,
|
||||
duration: Date.now() - this.startTime,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
private writeLog(entry: MigrationLogEntry): void {
|
||||
if (entry.level < this.minLogLevel) return;
|
||||
|
||||
const levelNames = ["DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"];
|
||||
const levelName = levelNames[entry.level];
|
||||
|
||||
// Console output with colors
|
||||
const colors = {
|
||||
[LogLevel.DEBUG]: "\x1b[36m", // Cyan
|
||||
[LogLevel.INFO]: "\x1b[32m", // Green
|
||||
[LogLevel.WARN]: "\x1b[33m", // Yellow
|
||||
[LogLevel.ERROR]: "\x1b[31m", // Red
|
||||
[LogLevel.CRITICAL]: "\x1b[35m", // Magenta
|
||||
};
|
||||
|
||||
const reset = "\x1b[0m";
|
||||
const color = colors[entry.level];
|
||||
|
||||
console.log(
|
||||
`${color}[${entry.timestamp}] ${levelName} [${entry.category}]${reset} ${entry.message}`
|
||||
);
|
||||
|
||||
if (entry.data) {
|
||||
console.log(` Data:`, entry.data);
|
||||
}
|
||||
|
||||
if (entry.error) {
|
||||
console.error(` Error:`, entry.error.message);
|
||||
if (entry.level >= LogLevel.ERROR) {
|
||||
console.error(` Stack:`, entry.error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// File output (structured)
|
||||
const logLine = JSON.stringify(entry) + "\n";
|
||||
appendFileSync(this.logFile, logLine);
|
||||
}
|
||||
|
||||
debug(category: string, message: string, data?: Record<string, unknown>): void {
|
||||
this.writeLog(this.createLogEntry(LogLevel.DEBUG, category, message, data));
|
||||
}
|
||||
|
||||
info(category: string, message: string, data?: Record<string, unknown>): void {
|
||||
this.writeLog(this.createLogEntry(LogLevel.INFO, category, message, data));
|
||||
}
|
||||
|
||||
warn(category: string, message: string, data?: Record<string, unknown>): void {
|
||||
this.writeLog(this.createLogEntry(LogLevel.WARN, category, message, data));
|
||||
}
|
||||
|
||||
error(category: string, message: string, error?: Error, data?: Record<string, unknown>): void {
|
||||
this.writeLog(this.createLogEntry(LogLevel.ERROR, category, message, data, error));
|
||||
}
|
||||
|
||||
critical(category: string, message: string, error?: Error, data?: Record<string, unknown>): void {
|
||||
this.writeLog(this.createLogEntry(LogLevel.CRITICAL, category, message, data, error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Time a function execution and log the result
|
||||
*/
|
||||
async timeExecution<T>(
|
||||
category: string,
|
||||
operationName: string,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
this.info(category, `Starting ${operationName}`);
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
const duration = Date.now() - startTime;
|
||||
this.info(category, `Completed ${operationName}`, { duration });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.error(category, `Failed ${operationName}`, error as Error, { duration });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a progress tracker for long-running operations
|
||||
*/
|
||||
createProgressTracker(category: string, total: number, operationName: string) {
|
||||
let completed = 0;
|
||||
|
||||
return {
|
||||
increment: (count: number = 1) => {
|
||||
completed += count;
|
||||
const percentage = Math.round((completed / total) * 100);
|
||||
this.info(category, `${operationName} progress: ${completed}/${total} (${percentage}%)`);
|
||||
},
|
||||
complete: () => {
|
||||
this.info(category, `${operationName} completed: ${completed}/${total}`);
|
||||
},
|
||||
fail: (error: Error) => {
|
||||
this.error(category, `${operationName} failed at ${completed}/${total}`, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log migration step start/completion
|
||||
*/
|
||||
startStep(stepName: string, description?: string): void {
|
||||
this.info("MIGRATION_STEP", `🚀 Starting: ${stepName}`, { description });
|
||||
}
|
||||
|
||||
completeStep(stepName: string, duration?: number): void {
|
||||
this.info("MIGRATION_STEP", `✅ Completed: ${stepName}`, { duration });
|
||||
}
|
||||
|
||||
failStep(stepName: string, error: Error): void {
|
||||
this.error("MIGRATION_STEP", `❌ Failed: ${stepName}`, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log migration phase transitions
|
||||
*/
|
||||
startPhase(phaseName: string, description?: string): void {
|
||||
this.info("MIGRATION_PHASE", `📋 Starting Phase: ${phaseName}`, { description });
|
||||
}
|
||||
|
||||
completePhase(phaseName: string): void {
|
||||
this.info("MIGRATION_PHASE", `🎉 Completed Phase: ${phaseName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the log session
|
||||
*/
|
||||
close(): void {
|
||||
const totalDuration = Date.now() - this.startTime;
|
||||
const footer = `
|
||||
=================================================================
|
||||
MIGRATION LOG SESSION ENDED
|
||||
=================================================================
|
||||
Total Duration: ${totalDuration}ms
|
||||
Time: ${new Date().toISOString()}
|
||||
=================================================================
|
||||
|
||||
`;
|
||||
appendFileSync(this.logFile, footer);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for easy access
|
||||
export const migrationLogger = new MigrationLogger();
|
||||
716
scripts/migration/pre-deployment-checks.ts
Normal file
716
scripts/migration/pre-deployment-checks.ts
Normal file
@ -0,0 +1,716 @@
|
||||
/**
|
||||
* Pre-Deployment Validation Checks
|
||||
*
|
||||
* Comprehensive validation suite that must pass before deploying
|
||||
* the new tRPC and batch processing architecture.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
import { DatabaseValidator } from "./validate-database";
|
||||
import { EnvironmentMigration } from "./environment-migration";
|
||||
|
||||
interface CheckResult {
|
||||
name: string;
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
duration: number;
|
||||
critical: boolean;
|
||||
}
|
||||
|
||||
interface PreDeploymentResult {
|
||||
success: boolean;
|
||||
checks: CheckResult[];
|
||||
totalDuration: number;
|
||||
criticalFailures: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
export class PreDeploymentChecker {
|
||||
private prisma: PrismaClient;
|
||||
private checks: CheckResult[] = [];
|
||||
|
||||
constructor() {
|
||||
this.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all pre-deployment checks
|
||||
*/
|
||||
async runAllChecks(): Promise<PreDeploymentResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.startPhase("PRE_DEPLOYMENT", "Running pre-deployment validation checks");
|
||||
|
||||
// Define all checks to run
|
||||
const checkSuite = [
|
||||
{ name: "Environment Configuration", fn: () => this.checkEnvironmentConfiguration(), critical: true },
|
||||
{ name: "Database Connection", fn: () => this.checkDatabaseConnection(), critical: true },
|
||||
{ name: "Database Schema", fn: () => this.checkDatabaseSchema(), critical: true },
|
||||
{ name: "Database Data Integrity", fn: () => this.checkDataIntegrity(), critical: true },
|
||||
{ name: "Dependencies", fn: () => this.checkDependencies(), critical: true },
|
||||
{ name: "File System Permissions", fn: () => this.checkFileSystemPermissions(), critical: false },
|
||||
{ name: "Port Availability", fn: () => this.checkPortAvailability(), critical: true },
|
||||
{ name: "OpenAI API Access", fn: () => this.checkOpenAIAccess(), critical: true },
|
||||
{ name: "tRPC Infrastructure", fn: () => this.checkTRPCInfrastructure(), critical: true },
|
||||
{ name: "Batch Processing Readiness", fn: () => this.checkBatchProcessingReadiness(), critical: true },
|
||||
{ name: "Security Configuration", fn: () => this.checkSecurityConfiguration(), critical: false },
|
||||
{ name: "Performance Configuration", fn: () => this.checkPerformanceConfiguration(), critical: false },
|
||||
{ name: "Backup Validation", fn: () => this.checkBackupValidation(), critical: false },
|
||||
{ name: "Migration Rollback Readiness", fn: () => this.checkRollbackReadiness(), critical: false },
|
||||
];
|
||||
|
||||
// Run all checks
|
||||
for (const check of checkSuite) {
|
||||
await this.runSingleCheck(check.name, check.fn, check.critical);
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const criticalFailures = this.checks.filter(c => c.critical && !c.success).length;
|
||||
const warningCount = this.checks.reduce((sum, c) => sum + c.warnings.length, 0);
|
||||
|
||||
const result: PreDeploymentResult = {
|
||||
success: criticalFailures === 0,
|
||||
checks: this.checks,
|
||||
totalDuration,
|
||||
criticalFailures,
|
||||
warningCount,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.completePhase("PRE_DEPLOYMENT");
|
||||
} else {
|
||||
migrationLogger.error("PRE_DEPLOYMENT", `Pre-deployment checks failed with ${criticalFailures} critical failures`);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("PRE_DEPLOYMENT", "Pre-deployment check suite failed", error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
await this.prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private async runSingleCheck(
|
||||
name: string,
|
||||
checkFn: () => Promise<Omit<CheckResult, 'name' | 'duration'>>,
|
||||
critical: boolean
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.info("CHECK", `Running: ${name}`);
|
||||
|
||||
const result = await checkFn();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const checkResult: CheckResult = {
|
||||
name,
|
||||
...result,
|
||||
duration,
|
||||
critical,
|
||||
};
|
||||
|
||||
this.checks.push(checkResult);
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.info("CHECK", `✅ ${name} passed`, { duration, warnings: result.warnings.length });
|
||||
} else {
|
||||
const level = critical ? "ERROR" : "WARN";
|
||||
migrationLogger[level.toLowerCase() as 'error' | 'warn']("CHECK", `❌ ${name} failed`, undefined, {
|
||||
errors: result.errors.length,
|
||||
warnings: result.warnings.length,
|
||||
duration
|
||||
});
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
migrationLogger.warn("CHECK", `${name} has warnings`, { warnings: result.warnings });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const checkResult: CheckResult = {
|
||||
name,
|
||||
success: false,
|
||||
errors: [`Check failed: ${(error as Error).message}`],
|
||||
warnings: [],
|
||||
duration,
|
||||
critical,
|
||||
};
|
||||
|
||||
this.checks.push(checkResult);
|
||||
migrationLogger.error("CHECK", `💥 ${name} crashed`, error as Error, { duration });
|
||||
}
|
||||
}
|
||||
|
||||
private async checkEnvironmentConfiguration(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const envMigration = new EnvironmentMigration();
|
||||
const result = await envMigration.validateEnvironmentConfiguration();
|
||||
|
||||
errors.push(...result.errors);
|
||||
warnings.push(...result.warnings);
|
||||
|
||||
// Additional environment checks
|
||||
const requiredVars = [
|
||||
'DATABASE_URL',
|
||||
'NEXTAUTH_SECRET',
|
||||
'OPENAI_API_KEY'
|
||||
];
|
||||
|
||||
for (const varName of requiredVars) {
|
||||
if (!process.env[varName]) {
|
||||
errors.push(`Missing required environment variable: ${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check new variables
|
||||
const newVars = [
|
||||
'BATCH_PROCESSING_ENABLED',
|
||||
'TRPC_ENDPOINT_URL'
|
||||
];
|
||||
|
||||
for (const varName of newVars) {
|
||||
if (!process.env[varName]) {
|
||||
warnings.push(`New environment variable not set: ${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`Environment validation failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabaseConnection(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Test basic connection
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
// Test connection pooling
|
||||
const connections = await Promise.all([
|
||||
this.prisma.$queryRaw`SELECT 1`,
|
||||
this.prisma.$queryRaw`SELECT 1`,
|
||||
this.prisma.$queryRaw`SELECT 1`,
|
||||
]);
|
||||
|
||||
if (connections.length !== 3) {
|
||||
warnings.push("Connection pooling may have issues");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`Database connection failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabaseSchema(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const validator = new DatabaseValidator();
|
||||
|
||||
try {
|
||||
const result = await validator.validateDatabase();
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
errors: result.errors,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [`Schema validation failed: ${(error as Error).message}`],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkDataIntegrity(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check for any corrupt data that could affect migration
|
||||
const sessionCount = await this.prisma.session.count();
|
||||
const importCount = await this.prisma.sessionImport.count();
|
||||
|
||||
if (sessionCount === 0 && importCount === 0) {
|
||||
warnings.push("No session data found - this may be a fresh installation");
|
||||
}
|
||||
|
||||
// Check for orphaned processing status records
|
||||
const orphanedStatus = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "SessionProcessingStatus" sps
|
||||
LEFT JOIN "Session" s ON sps."sessionId" = s.id
|
||||
WHERE s.id IS NULL
|
||||
`;
|
||||
|
||||
if (orphanedStatus[0]?.count > 0) {
|
||||
warnings.push(`Found ${orphanedStatus[0].count} orphaned processing status records`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`Data integrity check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDependencies(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check package.json
|
||||
const packagePath = join(process.cwd(), "package.json");
|
||||
if (!existsSync(packagePath)) {
|
||||
errors.push("package.json not found");
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packagePath, "utf8"));
|
||||
|
||||
// Check for required dependencies
|
||||
const requiredDeps = [
|
||||
"@trpc/server",
|
||||
"@trpc/client",
|
||||
"@trpc/next",
|
||||
"@prisma/client",
|
||||
"next",
|
||||
];
|
||||
|
||||
for (const dep of requiredDeps) {
|
||||
if (!packageJson.dependencies?.[dep] && !packageJson.devDependencies?.[dep]) {
|
||||
errors.push(`Missing required dependency: ${dep}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Node.js version
|
||||
const nodeVersion = process.version;
|
||||
const majorVersion = parseInt(nodeVersion.slice(1).split('.')[0]);
|
||||
|
||||
if (majorVersion < 18) {
|
||||
errors.push(`Node.js ${nodeVersion} is too old. Requires Node.js 18+`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`Dependency check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkFileSystemPermissions(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
|
||||
// Check if we can write to logs directory
|
||||
const logsDir = join(process.cwd(), "logs");
|
||||
try {
|
||||
await fs.mkdir(logsDir, { recursive: true });
|
||||
const testFile = join(logsDir, "test-write.tmp");
|
||||
await fs.writeFile(testFile, "test");
|
||||
await fs.unlink(testFile);
|
||||
} catch (error) {
|
||||
errors.push(`Cannot write to logs directory: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
// Check if we can write to backups directory
|
||||
const backupsDir = join(process.cwd(), "backups");
|
||||
try {
|
||||
await fs.mkdir(backupsDir, { recursive: true });
|
||||
const testFile = join(backupsDir, "test-write.tmp");
|
||||
await fs.writeFile(testFile, "test");
|
||||
await fs.unlink(testFile);
|
||||
} catch (error) {
|
||||
warnings.push(`Cannot write to backups directory: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`File system permission check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkPortAvailability(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const net = await import("node:net");
|
||||
const port = parseInt(process.env.PORT || "3000");
|
||||
|
||||
// Check if port is available
|
||||
const server = net.createServer();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.listen(port, () => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
warnings.push(`Port ${port} is already in use`);
|
||||
} else {
|
||||
errors.push(`Port check failed: ${err.message}`);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`Port availability check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkOpenAIAccess(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
errors.push("OPENAI_API_KEY not set");
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Test API access (simple models list call)
|
||||
const response = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
errors.push(`OpenAI API access failed: ${response.status} ${response.statusText}`);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
warnings.push("OpenAI API returned unexpected response format");
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`OpenAI API check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkTRPCInfrastructure(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if tRPC files exist
|
||||
const trpcFiles = [
|
||||
"app/api/trpc/[trpc]/route.ts",
|
||||
"server/routers/_app.ts",
|
||||
"lib/trpc.ts",
|
||||
];
|
||||
|
||||
for (const file of trpcFiles) {
|
||||
const fullPath = join(process.cwd(), file);
|
||||
if (!existsSync(fullPath)) {
|
||||
errors.push(`Missing tRPC file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tRPC types can be imported
|
||||
try {
|
||||
const { AppRouter } = await import("../../server/routers/_app");
|
||||
if (!AppRouter) {
|
||||
warnings.push("AppRouter type not found");
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`Cannot import tRPC router: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`tRPC infrastructure check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkBatchProcessingReadiness(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if batch processing files exist
|
||||
const batchFiles = [
|
||||
"lib/batchProcessor.ts",
|
||||
"lib/batchScheduler.ts",
|
||||
];
|
||||
|
||||
for (const file of batchFiles) {
|
||||
const fullPath = join(process.cwd(), file);
|
||||
if (!existsSync(fullPath)) {
|
||||
errors.push(`Missing batch processing file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check database readiness for batch processing
|
||||
const batchTableExists = await this.prisma.$queryRaw<{count: string}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM information_schema.tables
|
||||
WHERE table_name = 'AIBatchRequest'
|
||||
`;
|
||||
|
||||
if (parseInt(batchTableExists[0]?.count || '0') === 0) {
|
||||
errors.push("AIBatchRequest table not found");
|
||||
}
|
||||
|
||||
// Check if batch status enum exists
|
||||
const batchStatusExists = await this.prisma.$queryRaw<{count: string}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM pg_type
|
||||
WHERE typname = 'AIBatchRequestStatus'
|
||||
`;
|
||||
|
||||
if (parseInt(batchStatusExists[0]?.count || '0') === 0) {
|
||||
errors.push("AIBatchRequestStatus enum not found");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
errors.push(`Batch processing readiness check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkSecurityConfiguration(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check NEXTAUTH_SECRET strength
|
||||
const secret = process.env.NEXTAUTH_SECRET;
|
||||
if (secret && secret.length < 32) {
|
||||
warnings.push("NEXTAUTH_SECRET should be at least 32 characters long");
|
||||
}
|
||||
|
||||
// Check if rate limiting is configured
|
||||
if (!process.env.RATE_LIMIT_WINDOW_MS) {
|
||||
warnings.push("Rate limiting not configured");
|
||||
}
|
||||
|
||||
// Check if we're running in production mode with proper settings
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.includes("localhost")) {
|
||||
warnings.push("NEXTAUTH_URL should not use localhost in production");
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
warnings.push(`Security configuration check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkPerformanceConfiguration(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check database connection limits
|
||||
const connectionLimit = parseInt(process.env.DATABASE_CONNECTION_LIMIT || "20");
|
||||
if (connectionLimit < 10) {
|
||||
warnings.push("DATABASE_CONNECTION_LIMIT may be too low for production");
|
||||
}
|
||||
|
||||
// Check batch processing configuration
|
||||
const batchMaxRequests = parseInt(process.env.BATCH_MAX_REQUESTS || "1000");
|
||||
if (batchMaxRequests > 50000) {
|
||||
warnings.push("BATCH_MAX_REQUESTS exceeds OpenAI limits");
|
||||
}
|
||||
|
||||
// Check session processing concurrency
|
||||
const concurrency = parseInt(process.env.SESSION_PROCESSING_CONCURRENCY || "5");
|
||||
if (concurrency > 10) {
|
||||
warnings.push("High SESSION_PROCESSING_CONCURRENCY may overwhelm the system");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
warnings.push(`Performance configuration check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkBackupValidation(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if pg_dump is available
|
||||
const { execSync } = await import("node:child_process");
|
||||
|
||||
try {
|
||||
execSync("pg_dump --version", { stdio: "ignore" });
|
||||
} catch (error) {
|
||||
errors.push("pg_dump not found - database backup will not work");
|
||||
}
|
||||
|
||||
// Check backup directory
|
||||
const backupDir = join(process.cwd(), "backups");
|
||||
if (!existsSync(backupDir)) {
|
||||
warnings.push("Backup directory does not exist");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
warnings.push(`Backup validation failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkRollbackReadiness(): Promise<Omit<CheckResult, 'name' | 'duration'>> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// Check if rollback scripts exist
|
||||
const rollbackFiles = [
|
||||
"scripts/migration/rollback.ts",
|
||||
"scripts/migration/restore-database.ts",
|
||||
];
|
||||
|
||||
for (const file of rollbackFiles) {
|
||||
const fullPath = join(process.cwd(), file);
|
||||
if (!existsSync(fullPath)) {
|
||||
warnings.push(`Missing rollback file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if migration mode allows rollback
|
||||
if (process.env.MIGRATION_ROLLBACK_ENABLED !== "true") {
|
||||
warnings.push("Rollback is disabled - consider enabling for safety");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
warnings.push(`Rollback readiness check failed: ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const checker = new PreDeploymentChecker();
|
||||
|
||||
checker.runAllChecks()
|
||||
.then((result) => {
|
||||
console.log('\n=== PRE-DEPLOYMENT CHECK RESULTS ===');
|
||||
console.log(`Overall Success: ${result.success ? '✅' : '❌'}`);
|
||||
console.log(`Total Duration: ${result.totalDuration}ms`);
|
||||
console.log(`Critical Failures: ${result.criticalFailures}`);
|
||||
console.log(`Total Warnings: ${result.warningCount}`);
|
||||
|
||||
console.log('\n=== INDIVIDUAL CHECKS ===');
|
||||
for (const check of result.checks) {
|
||||
const status = check.success ? '✅' : '❌';
|
||||
const critical = check.critical ? ' (CRITICAL)' : '';
|
||||
console.log(`${status} ${check.name}${critical} (${check.duration}ms)`);
|
||||
|
||||
if (check.errors.length > 0) {
|
||||
check.errors.forEach(error => console.log(` ❌ ${error}`));
|
||||
}
|
||||
|
||||
if (check.warnings.length > 0) {
|
||||
check.warnings.forEach(warning => console.log(` ⚠️ ${warning}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
console.log('\n❌ DEPLOYMENT BLOCKED - Fix critical issues before proceeding');
|
||||
} else if (result.warningCount > 0) {
|
||||
console.log('\n⚠️ DEPLOYMENT ALLOWED - Review warnings before proceeding');
|
||||
} else {
|
||||
console.log('\n✅ DEPLOYMENT READY - All checks passed');
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Pre-deployment checks failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
678
scripts/migration/rollback.ts
Normal file
678
scripts/migration/rollback.ts
Normal file
@ -0,0 +1,678 @@
|
||||
/**
|
||||
* Deployment Rollback System
|
||||
*
|
||||
* Provides comprehensive rollback capabilities to restore the system
|
||||
* to a previous state in case of deployment failures.
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
|
||||
interface RollbackOptions {
|
||||
backupPath?: string;
|
||||
rollbackDatabase: boolean;
|
||||
rollbackCode: boolean;
|
||||
rollbackEnvironment: boolean;
|
||||
skipConfirmation: boolean;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
interface RollbackStep {
|
||||
name: string;
|
||||
description: string;
|
||||
critical: boolean;
|
||||
execute: () => Promise<void>;
|
||||
verify?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface RollbackResult {
|
||||
success: boolean;
|
||||
completedSteps: string[];
|
||||
failedStep?: string;
|
||||
totalDuration: number;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class RollbackManager {
|
||||
private readonly defaultOptions: RollbackOptions = {
|
||||
rollbackDatabase: true,
|
||||
rollbackCode: true,
|
||||
rollbackEnvironment: true,
|
||||
skipConfirmation: false,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
private options: RollbackOptions;
|
||||
private steps: RollbackStep[] = [];
|
||||
private completedSteps: string[] = [];
|
||||
|
||||
constructor(options?: Partial<RollbackOptions>) {
|
||||
this.options = { ...this.defaultOptions, ...options };
|
||||
this.setupRollbackSteps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute complete rollback process
|
||||
*/
|
||||
async rollback(): Promise<RollbackResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.startPhase("ROLLBACK", "Starting deployment rollback");
|
||||
|
||||
// Confirmation check
|
||||
if (!this.options.skipConfirmation && !this.options.dryRun) {
|
||||
await this.confirmRollback();
|
||||
}
|
||||
|
||||
// Execute rollback steps
|
||||
for (const step of this.steps) {
|
||||
await this.executeRollbackStep(step);
|
||||
this.completedSteps.push(step.name);
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
migrationLogger.completePhase("ROLLBACK");
|
||||
migrationLogger.info("ROLLBACK", "Rollback completed successfully", {
|
||||
totalDuration,
|
||||
steps: this.completedSteps.length
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
completedSteps: this.completedSteps,
|
||||
totalDuration,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
migrationLogger.error("ROLLBACK", "Rollback failed", error as Error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
completedSteps: this.completedSteps,
|
||||
totalDuration,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rollback snapshot before deployment
|
||||
*/
|
||||
async createRollbackSnapshot(): Promise<string> {
|
||||
migrationLogger.startStep("ROLLBACK_SNAPSHOT", "Creating rollback snapshot");
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const snapshotDir = join(process.cwd(), "rollback-snapshots", timestamp);
|
||||
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.mkdir(snapshotDir, { recursive: true });
|
||||
|
||||
// Save environment snapshot
|
||||
await this.saveEnvironmentSnapshot(snapshotDir);
|
||||
|
||||
// Save package.json and lock file snapshot
|
||||
await this.savePackageSnapshot(snapshotDir);
|
||||
|
||||
// Save git commit information
|
||||
await this.saveGitSnapshot(snapshotDir);
|
||||
|
||||
// Save deployment state
|
||||
await this.saveDeploymentState(snapshotDir);
|
||||
|
||||
migrationLogger.completeStep("ROLLBACK_SNAPSHOT");
|
||||
migrationLogger.info("ROLLBACK_SNAPSHOT", "Rollback snapshot created", { snapshotDir });
|
||||
|
||||
return snapshotDir;
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.failStep("ROLLBACK_SNAPSHOT", error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupRollbackSteps(): void {
|
||||
this.steps = [
|
||||
{
|
||||
name: "Pre-Rollback Validation",
|
||||
description: "Validate rollback prerequisites",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.validateRollbackPrerequisites();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Stop Services",
|
||||
description: "Stop application services safely",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.stopServices();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Database Rollback",
|
||||
description: "Restore database to previous state",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
if (this.options.rollbackDatabase) {
|
||||
await this.rollbackDatabase();
|
||||
} else {
|
||||
migrationLogger.info("DB_ROLLBACK", "Database rollback skipped");
|
||||
}
|
||||
},
|
||||
verify: async () => {
|
||||
return await this.verifyDatabaseRollback();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Code Rollback",
|
||||
description: "Restore application code to previous version",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
if (this.options.rollbackCode) {
|
||||
await this.rollbackCode();
|
||||
} else {
|
||||
migrationLogger.info("CODE_ROLLBACK", "Code rollback skipped");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Environment Rollback",
|
||||
description: "Restore environment configuration",
|
||||
critical: false,
|
||||
execute: async () => {
|
||||
if (this.options.rollbackEnvironment) {
|
||||
await this.rollbackEnvironment();
|
||||
} else {
|
||||
migrationLogger.info("ENV_ROLLBACK", "Environment rollback skipped");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Dependencies Restoration",
|
||||
description: "Restore previous dependencies",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.restoreDependencies();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Restart Services",
|
||||
description: "Restart services with previous configuration",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.restartServices();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Verify Rollback",
|
||||
description: "Verify system is working correctly",
|
||||
critical: true,
|
||||
execute: async () => {
|
||||
await this.verifyRollback();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async executeRollbackStep(step: RollbackStep): Promise<void> {
|
||||
try {
|
||||
migrationLogger.startStep(step.name.replace(/\s+/g, '_').toUpperCase(), step.description);
|
||||
|
||||
if (this.options.dryRun) {
|
||||
migrationLogger.info("DRY_RUN", `Would execute rollback: ${step.name}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} else {
|
||||
await step.execute();
|
||||
}
|
||||
|
||||
// Run verification if provided
|
||||
if (step.verify && !this.options.dryRun) {
|
||||
const verified = await step.verify();
|
||||
if (!verified) {
|
||||
throw new Error(`Verification failed for rollback step: ${step.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
migrationLogger.completeStep(step.name.replace(/\s+/g, '_').toUpperCase());
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.failStep(step.name.replace(/\s+/g, '_').toUpperCase(), error as Error);
|
||||
|
||||
if (step.critical) {
|
||||
throw error;
|
||||
} else {
|
||||
migrationLogger.warn("ROLLBACK_STEP", `Non-critical rollback step failed: ${step.name}`, {
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async confirmRollback(): Promise<void> {
|
||||
console.log('\n⚠️ ROLLBACK CONFIRMATION REQUIRED ⚠️');
|
||||
console.log('This will restore the system to a previous state.');
|
||||
console.log('The following actions will be performed:');
|
||||
|
||||
if (this.options.rollbackDatabase) {
|
||||
console.log(' - Restore database from backup');
|
||||
}
|
||||
if (this.options.rollbackCode) {
|
||||
console.log(' - Restore application code to previous version');
|
||||
}
|
||||
if (this.options.rollbackEnvironment) {
|
||||
console.log(' - Restore environment configuration');
|
||||
}
|
||||
|
||||
console.log('\nThis operation cannot be easily undone.');
|
||||
|
||||
// In a real implementation, you would prompt for user input
|
||||
// For automation purposes, we'll check for a confirmation flag
|
||||
if (!process.env.ROLLBACK_CONFIRMED) {
|
||||
throw new Error('Rollback not confirmed. Set ROLLBACK_CONFIRMED=true to proceed.');
|
||||
}
|
||||
}
|
||||
|
||||
private async validateRollbackPrerequisites(): Promise<void> {
|
||||
migrationLogger.info("ROLLBACK_VALIDATION", "Validating rollback prerequisites");
|
||||
|
||||
// Check if backup exists
|
||||
if (this.options.rollbackDatabase && this.options.backupPath) {
|
||||
if (!existsSync(this.options.backupPath)) {
|
||||
throw new Error(`Backup file not found: ${this.options.backupPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if pg_restore is available for database rollback
|
||||
if (this.options.rollbackDatabase) {
|
||||
try {
|
||||
execSync("pg_restore --version", { stdio: "ignore" });
|
||||
} catch (error) {
|
||||
throw new Error("pg_restore not found - database rollback not possible");
|
||||
}
|
||||
}
|
||||
|
||||
// Check git status for code rollback
|
||||
if (this.options.rollbackCode) {
|
||||
try {
|
||||
execSync("git status", { stdio: "ignore" });
|
||||
} catch (error) {
|
||||
throw new Error("Git not available - code rollback not possible");
|
||||
}
|
||||
}
|
||||
|
||||
migrationLogger.info("ROLLBACK_VALIDATION", "Prerequisites validated successfully");
|
||||
}
|
||||
|
||||
private async stopServices(): Promise<void> {
|
||||
migrationLogger.info("SERVICE_STOP", "Stopping application services");
|
||||
|
||||
// In a real deployment, this would stop the actual services
|
||||
// For this implementation, we'll simulate service stopping
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
migrationLogger.info("SERVICE_STOP", "Services stopped successfully");
|
||||
}
|
||||
|
||||
private async rollbackDatabase(): Promise<void> {
|
||||
if (!this.options.backupPath) {
|
||||
migrationLogger.warn("DB_ROLLBACK", "No backup path specified, skipping database rollback");
|
||||
return;
|
||||
}
|
||||
|
||||
migrationLogger.info("DB_ROLLBACK", `Restoring database from backup: ${this.options.backupPath}`);
|
||||
|
||||
try {
|
||||
// Parse database URL
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) {
|
||||
throw new Error("DATABASE_URL not found");
|
||||
}
|
||||
|
||||
const parsed = new URL(dbUrl);
|
||||
|
||||
// Drop existing connections
|
||||
migrationLogger.info("DB_ROLLBACK", "Terminating existing database connections");
|
||||
|
||||
// Restore from backup
|
||||
const restoreCommand = [
|
||||
"pg_restore",
|
||||
"-h", parsed.hostname,
|
||||
"-p", parsed.port || "5432",
|
||||
"-U", parsed.username,
|
||||
"-d", parsed.pathname.slice(1),
|
||||
"--clean",
|
||||
"--if-exists",
|
||||
"--verbose",
|
||||
this.options.backupPath
|
||||
].join(" ");
|
||||
|
||||
migrationLogger.debug("DB_ROLLBACK", `Executing: ${restoreCommand}`);
|
||||
|
||||
execSync(restoreCommand, {
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: parsed.password,
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
migrationLogger.info("DB_ROLLBACK", "Database rollback completed successfully");
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Database rollback failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyDatabaseRollback(): Promise<boolean> {
|
||||
try {
|
||||
migrationLogger.info("DB_VERIFY", "Verifying database rollback");
|
||||
|
||||
// Test database connection
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
await prisma.$disconnect();
|
||||
|
||||
migrationLogger.info("DB_VERIFY", "Database verification successful");
|
||||
return true;
|
||||
} catch (error) {
|
||||
await prisma.$disconnect();
|
||||
migrationLogger.error("DB_VERIFY", "Database verification failed", error as Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("DB_VERIFY", "Database verification error", error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async rollbackCode(): Promise<void> {
|
||||
migrationLogger.info("CODE_ROLLBACK", "Rolling back application code");
|
||||
|
||||
try {
|
||||
// Get the previous commit (this is a simplified approach)
|
||||
const previousCommit = execSync("git rev-parse HEAD~1", {
|
||||
encoding: "utf8"
|
||||
}).trim();
|
||||
|
||||
migrationLogger.info("CODE_ROLLBACK", `Rolling back to commit: ${previousCommit}`);
|
||||
|
||||
// Reset to previous commit
|
||||
execSync(`git reset --hard ${previousCommit}`, { stdio: "pipe" });
|
||||
|
||||
migrationLogger.info("CODE_ROLLBACK", "Code rollback completed successfully");
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Code rollback failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async rollbackEnvironment(): Promise<void> {
|
||||
migrationLogger.info("ENV_ROLLBACK", "Rolling back environment configuration");
|
||||
|
||||
try {
|
||||
// Look for environment backup
|
||||
const backupFiles = [
|
||||
".env.local.backup",
|
||||
".env.backup",
|
||||
".env.production.backup"
|
||||
];
|
||||
|
||||
let restored = false;
|
||||
|
||||
for (const backupFile of backupFiles) {
|
||||
const backupPath = join(process.cwd(), backupFile);
|
||||
const targetPath = backupPath.replace('.backup', '');
|
||||
|
||||
if (existsSync(backupPath)) {
|
||||
const backupContent = readFileSync(backupPath, "utf8");
|
||||
writeFileSync(targetPath, backupContent);
|
||||
|
||||
migrationLogger.info("ENV_ROLLBACK", `Restored ${targetPath} from ${backupFile}`);
|
||||
restored = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!restored) {
|
||||
migrationLogger.warn("ENV_ROLLBACK", "No environment backup found to restore");
|
||||
} else {
|
||||
migrationLogger.info("ENV_ROLLBACK", "Environment rollback completed successfully");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Environment rollback failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreDependencies(): Promise<void> {
|
||||
migrationLogger.info("DEPS_RESTORE", "Restoring dependencies");
|
||||
|
||||
try {
|
||||
// Check if package-lock.json backup exists
|
||||
const packageLockBackup = join(process.cwd(), "package-lock.json.backup");
|
||||
const packageLock = join(process.cwd(), "package-lock.json");
|
||||
|
||||
if (existsSync(packageLockBackup)) {
|
||||
const backupContent = readFileSync(packageLockBackup, "utf8");
|
||||
writeFileSync(packageLock, backupContent);
|
||||
migrationLogger.info("DEPS_RESTORE", "Restored package-lock.json from backup");
|
||||
}
|
||||
|
||||
// Reinstall dependencies
|
||||
execSync("npm ci", { stdio: "pipe" });
|
||||
|
||||
migrationLogger.info("DEPS_RESTORE", "Dependencies restored successfully");
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Dependencies restoration failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async restartServices(): Promise<void> {
|
||||
migrationLogger.info("SERVICE_RESTART", "Restarting services after rollback");
|
||||
|
||||
// In a real deployment, this would restart the actual services
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
migrationLogger.info("SERVICE_RESTART", "Services restarted successfully");
|
||||
}
|
||||
|
||||
private async verifyRollback(): Promise<void> {
|
||||
migrationLogger.info("ROLLBACK_VERIFY", "Verifying rollback success");
|
||||
|
||||
try {
|
||||
// Test database connection
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
await prisma.$disconnect();
|
||||
|
||||
// Test basic application functionality
|
||||
// This would typically involve checking key endpoints or services
|
||||
|
||||
migrationLogger.info("ROLLBACK_VERIFY", "Rollback verification successful");
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Rollback verification failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveEnvironmentSnapshot(snapshotDir: string): Promise<void> {
|
||||
const fs = await import("node:fs/promises");
|
||||
|
||||
const envFiles = [".env.local", ".env.production", ".env"];
|
||||
|
||||
for (const envFile of envFiles) {
|
||||
const envPath = join(process.cwd(), envFile);
|
||||
if (existsSync(envPath)) {
|
||||
const content = await fs.readFile(envPath, "utf8");
|
||||
await fs.writeFile(join(snapshotDir, envFile), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async savePackageSnapshot(snapshotDir: string): Promise<void> {
|
||||
const fs = await import("node:fs/promises");
|
||||
|
||||
const packageFiles = ["package.json", "package-lock.json", "pnpm-lock.yaml"];
|
||||
|
||||
for (const packageFile of packageFiles) {
|
||||
const packagePath = join(process.cwd(), packageFile);
|
||||
if (existsSync(packagePath)) {
|
||||
const content = await fs.readFile(packagePath, "utf8");
|
||||
await fs.writeFile(join(snapshotDir, packageFile), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async saveGitSnapshot(snapshotDir: string): Promise<void> {
|
||||
try {
|
||||
const gitInfo = {
|
||||
commit: execSync("git rev-parse HEAD", { encoding: "utf8" }).trim(),
|
||||
branch: execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8" }).trim(),
|
||||
status: execSync("git status --porcelain", { encoding: "utf8" }).trim(),
|
||||
remotes: execSync("git remote -v", { encoding: "utf8" }).trim(),
|
||||
};
|
||||
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.writeFile(
|
||||
join(snapshotDir, "git-info.json"),
|
||||
JSON.stringify(gitInfo, null, 2)
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.warn("GIT_SNAPSHOT", "Failed to save git snapshot", {
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async saveDeploymentState(snapshotDir: string): Promise<void> {
|
||||
const deploymentState = {
|
||||
timestamp: new Date().toISOString(),
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
environment: process.env.NODE_ENV,
|
||||
rollbackOptions: this.options,
|
||||
};
|
||||
|
||||
const fs = await import("node:fs/promises");
|
||||
await fs.writeFile(
|
||||
join(snapshotDir, "deployment-state.json"),
|
||||
JSON.stringify(deploymentState, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
const options: Partial<RollbackOptions> = {};
|
||||
|
||||
// Parse command line arguments
|
||||
args.forEach((arg, index) => {
|
||||
switch (arg) {
|
||||
case "--dry-run":
|
||||
options.dryRun = true;
|
||||
break;
|
||||
case "--skip-confirmation":
|
||||
options.skipConfirmation = true;
|
||||
break;
|
||||
case "--no-database":
|
||||
options.rollbackDatabase = false;
|
||||
break;
|
||||
case "--no-code":
|
||||
options.rollbackCode = false;
|
||||
break;
|
||||
case "--no-environment":
|
||||
options.rollbackEnvironment = false;
|
||||
break;
|
||||
case "--backup":
|
||||
options.backupPath = args[index + 1];
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const command = args[0];
|
||||
|
||||
if (command === "snapshot") {
|
||||
const rollbackManager = new RollbackManager();
|
||||
rollbackManager.createRollbackSnapshot()
|
||||
.then((snapshotDir) => {
|
||||
console.log('\n=== ROLLBACK SNAPSHOT CREATED ===');
|
||||
console.log(`Snapshot Directory: ${snapshotDir}`);
|
||||
console.log('\nThe snapshot contains:');
|
||||
console.log(' - Environment configuration');
|
||||
console.log(' - Package dependencies');
|
||||
console.log(' - Git information');
|
||||
console.log(' - Deployment state');
|
||||
console.log('\nUse this snapshot for rollback if needed.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Snapshot creation failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
const rollbackManager = new RollbackManager(options);
|
||||
|
||||
rollbackManager.rollback()
|
||||
.then((result) => {
|
||||
console.log('\n=== ROLLBACK RESULTS ===');
|
||||
console.log(`Success: ${result.success ? '✅' : '❌'}`);
|
||||
console.log(`Total Duration: ${result.totalDuration}ms`);
|
||||
console.log(`Completed Steps: ${result.completedSteps.length}`);
|
||||
|
||||
if (result.failedStep) {
|
||||
console.log(`Failed Step: ${result.failedStep}`);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Error: ${result.error.message}`);
|
||||
}
|
||||
|
||||
console.log('\nCompleted Steps:');
|
||||
result.completedSteps.forEach(step => console.log(` ✅ ${step}`));
|
||||
|
||||
if (result.success) {
|
||||
console.log('\n🎉 ROLLBACK SUCCESSFUL!');
|
||||
console.log('\nNext Steps:');
|
||||
console.log('1. Verify system functionality');
|
||||
console.log('2. Monitor logs for any issues');
|
||||
console.log('3. Investigate root cause of deployment failure');
|
||||
} else {
|
||||
console.log('\n💥 ROLLBACK FAILED!');
|
||||
console.log('\nNext Steps:');
|
||||
console.log('1. Check logs for error details');
|
||||
console.log('2. Manual intervention may be required');
|
||||
console.log('3. Contact system administrators');
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Rollback failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
526
scripts/migration/trpc-endpoint-tests.ts
Normal file
526
scripts/migration/trpc-endpoint-tests.ts
Normal file
@ -0,0 +1,526 @@
|
||||
/**
|
||||
* tRPC Endpoint Validation Tests
|
||||
*
|
||||
* Comprehensive tests to validate tRPC endpoints are working correctly
|
||||
* after deployment of the new architecture.
|
||||
*/
|
||||
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
|
||||
interface EndpointTest {
|
||||
name: string;
|
||||
path: string;
|
||||
method: string;
|
||||
payload?: unknown;
|
||||
expectedStatuses: number[];
|
||||
timeout: number;
|
||||
critical: boolean;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
success: boolean;
|
||||
status: number;
|
||||
duration: number;
|
||||
response?: unknown;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface TRPCTestResult {
|
||||
success: boolean;
|
||||
tests: TestResult[];
|
||||
totalDuration: number;
|
||||
passedTests: number;
|
||||
failedTests: number;
|
||||
criticalFailures: number;
|
||||
}
|
||||
|
||||
export class TRPCEndpointTester {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
|
||||
constructor(baseUrl?: string, timeout: number = 30000) {
|
||||
this.baseUrl = baseUrl || process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comprehensive tRPC endpoint tests
|
||||
*/
|
||||
async runEndpointTests(): Promise<TRPCTestResult> {
|
||||
const startTime = Date.now();
|
||||
const tests: TestResult[] = [];
|
||||
|
||||
try {
|
||||
migrationLogger.startStep("TRPC_TESTS", "Running tRPC endpoint validation tests");
|
||||
|
||||
// Define test suite
|
||||
const endpointTests: EndpointTest[] = [
|
||||
// Authentication endpoints
|
||||
{
|
||||
name: "Auth - Get Session",
|
||||
path: "/api/trpc/auth.getSession",
|
||||
method: "POST",
|
||||
payload: { json: null },
|
||||
expectedStatuses: [200, 401], // 401 is OK for unauthenticated requests
|
||||
timeout: 5000,
|
||||
critical: true,
|
||||
},
|
||||
|
||||
// Dashboard endpoints
|
||||
{
|
||||
name: "Dashboard - Get Metrics",
|
||||
path: "/api/trpc/dashboard.getMetrics",
|
||||
method: "POST",
|
||||
payload: { json: { dateRange: "7d" } },
|
||||
expectedStatuses: [200, 401, 403],
|
||||
timeout: 10000,
|
||||
critical: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "Dashboard - Get Sessions",
|
||||
path: "/api/trpc/dashboard.getSessions",
|
||||
method: "POST",
|
||||
payload: {
|
||||
json: {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
filters: {}
|
||||
}
|
||||
},
|
||||
expectedStatuses: [200, 401, 403],
|
||||
timeout: 10000,
|
||||
critical: true,
|
||||
},
|
||||
|
||||
{
|
||||
name: "Dashboard - Get Session Filter Options",
|
||||
path: "/api/trpc/dashboard.getSessionFilterOptions",
|
||||
method: "POST",
|
||||
payload: { json: null },
|
||||
expectedStatuses: [200, 401, 403],
|
||||
timeout: 5000,
|
||||
critical: false,
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
{
|
||||
name: "Admin - Get System Health",
|
||||
path: "/api/trpc/admin.getSystemHealth",
|
||||
method: "POST",
|
||||
payload: { json: null },
|
||||
expectedStatuses: [200, 401, 403],
|
||||
timeout: 15000,
|
||||
critical: false,
|
||||
},
|
||||
|
||||
{
|
||||
name: "Admin - Get Processing Status",
|
||||
path: "/api/trpc/admin.getProcessingStatus",
|
||||
method: "POST",
|
||||
payload: { json: null },
|
||||
expectedStatuses: [200, 401, 403],
|
||||
timeout: 10000,
|
||||
critical: false,
|
||||
},
|
||||
|
||||
// Batch request endpoints (if available)
|
||||
{
|
||||
name: "Admin - Get Batch Requests",
|
||||
path: "/api/trpc/admin.getBatchRequests",
|
||||
method: "POST",
|
||||
payload: { json: { page: 1, pageSize: 10 } },
|
||||
expectedStatuses: [200, 401, 403, 404], // 404 OK if endpoint doesn't exist yet
|
||||
timeout: 10000,
|
||||
critical: false,
|
||||
},
|
||||
|
||||
// Test invalid endpoint (should return 404)
|
||||
{
|
||||
name: "Invalid Endpoint Test",
|
||||
path: "/api/trpc/nonexistent.invalidMethod",
|
||||
method: "POST",
|
||||
payload: { json: null },
|
||||
expectedStatuses: [404, 400],
|
||||
timeout: 5000,
|
||||
critical: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Run all tests
|
||||
for (const test of endpointTests) {
|
||||
const result = await this.runSingleTest(test);
|
||||
tests.push(result);
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const passedTests = tests.filter(t => t.success).length;
|
||||
const failedTests = tests.filter(t => !t.success).length;
|
||||
const criticalFailures = tests.filter(t => !t.success && endpointTests.find(et => et.name === t.name)?.critical).length;
|
||||
|
||||
const result: TRPCTestResult = {
|
||||
success: criticalFailures === 0,
|
||||
tests,
|
||||
totalDuration,
|
||||
passedTests,
|
||||
failedTests,
|
||||
criticalFailures,
|
||||
};
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.completeStep("TRPC_TESTS");
|
||||
} else {
|
||||
migrationLogger.failStep("TRPC_TESTS", new Error(`${criticalFailures} critical tRPC tests failed`));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
migrationLogger.error("TRPC_TESTS", "tRPC test suite failed", error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async runSingleTest(test: EndpointTest): Promise<TestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.debug("TRPC_TEST", `Testing: ${test.name}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), test.timeout);
|
||||
|
||||
const url = `${this.baseUrl}${test.path}`;
|
||||
const response = await fetch(url, {
|
||||
method: test.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: test.payload ? JSON.stringify(test.payload) : null,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Check if status is expected
|
||||
const success = test.expectedStatuses.includes(response.status);
|
||||
|
||||
let responseData: unknown;
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
// Response might not be JSON, that's OK
|
||||
responseData = await response.text();
|
||||
}
|
||||
|
||||
const result: TestResult = {
|
||||
name: test.name,
|
||||
success,
|
||||
status: response.status,
|
||||
duration,
|
||||
response: responseData,
|
||||
};
|
||||
|
||||
if (success) {
|
||||
migrationLogger.debug("TRPC_TEST", `✅ ${test.name} passed`, {
|
||||
status: response.status,
|
||||
duration
|
||||
});
|
||||
} else {
|
||||
migrationLogger.warn("TRPC_TEST", `❌ ${test.name} failed`, {
|
||||
status: response.status,
|
||||
expected: test.expectedStatuses,
|
||||
duration
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
migrationLogger.error("TRPC_TEST", `💥 ${test.name} crashed`, error as Error, { duration });
|
||||
|
||||
return {
|
||||
name: test.name,
|
||||
success: false,
|
||||
status: 0,
|
||||
duration,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tRPC batch requests
|
||||
*/
|
||||
async testBatchRequests(): Promise<TestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.info("TRPC_BATCH", "Testing tRPC batch requests");
|
||||
|
||||
// Create a batch request with multiple calls
|
||||
const batchPayload = [
|
||||
{
|
||||
id: 1,
|
||||
jsonrpc: "2.0",
|
||||
method: "query",
|
||||
params: {
|
||||
path: "auth.getSession",
|
||||
input: { json: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
jsonrpc: "2.0",
|
||||
method: "query",
|
||||
params: {
|
||||
path: "dashboard.getMetrics",
|
||||
input: { json: { dateRange: "7d" } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/trpc`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(batchPayload),
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const responseData = await response.json();
|
||||
|
||||
// Batch requests should return an array of responses
|
||||
const success = response.ok && Array.isArray(responseData) && responseData.length === 2;
|
||||
|
||||
return {
|
||||
name: "tRPC Batch Requests",
|
||||
success,
|
||||
status: response.status,
|
||||
duration,
|
||||
response: responseData,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
name: "tRPC Batch Requests",
|
||||
success: false,
|
||||
status: 0,
|
||||
duration,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tRPC subscription endpoints (if available)
|
||||
*/
|
||||
async testSubscriptions(): Promise<TestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
migrationLogger.info("TRPC_SUBSCRIPTIONS", "Testing tRPC subscriptions");
|
||||
|
||||
// Test if WebSocket connection is available for subscriptions
|
||||
const wsUrl = this.baseUrl.replace(/^https?/, "ws") + "/api/trpc";
|
||||
|
||||
return new Promise<TestResult>((resolve) => {
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve({
|
||||
name: "tRPC Subscriptions",
|
||||
success: false,
|
||||
status: 0,
|
||||
duration: Date.now() - startTime,
|
||||
error: new Error("WebSocket connection timeout"),
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
resolve({
|
||||
name: "tRPC Subscriptions",
|
||||
success: true,
|
||||
status: 200,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
name: "tRPC Subscriptions",
|
||||
success: false,
|
||||
status: 0,
|
||||
duration: Date.now() - startTime,
|
||||
error: new Error("WebSocket connection failed"),
|
||||
});
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
resolve({
|
||||
name: "tRPC Subscriptions",
|
||||
success: false,
|
||||
status: 0,
|
||||
duration: Date.now() - startTime,
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
name: "tRPC Subscriptions",
|
||||
success: false,
|
||||
status: 0,
|
||||
duration,
|
||||
error: error as Error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate test report
|
||||
*/
|
||||
generateTestReport(result: TRPCTestResult): string {
|
||||
const report = `
|
||||
# tRPC Endpoint Test Report
|
||||
|
||||
**Overall Status**: ${result.success ? '✅ All Critical Tests Passed' : '❌ Critical Tests Failed'}
|
||||
**Total Duration**: ${result.totalDuration}ms
|
||||
**Passed Tests**: ${result.passedTests}/${result.tests.length}
|
||||
**Failed Tests**: ${result.failedTests}/${result.tests.length}
|
||||
**Critical Failures**: ${result.criticalFailures}
|
||||
|
||||
## Test Results
|
||||
|
||||
${result.tests.map(test => `
|
||||
### ${test.name}
|
||||
- **Status**: ${test.success ? '✅ Pass' : '❌ Fail'}
|
||||
- **HTTP Status**: ${test.status}
|
||||
- **Duration**: ${test.duration}ms
|
||||
${test.error ? `- **Error**: ${test.error.message}` : ''}
|
||||
${test.response && typeof test.response === 'object' ? `- **Response**: \`\`\`json\n${JSON.stringify(test.response, null, 2)}\n\`\`\`` : ''}
|
||||
`).join('')}
|
||||
|
||||
## Summary
|
||||
|
||||
${result.success ?
|
||||
'🎉 All critical tRPC endpoints are working correctly!' :
|
||||
`⚠️ ${result.criticalFailures} critical endpoint(s) failed. Please review and fix the issues above.`
|
||||
}
|
||||
|
||||
## Recommendations
|
||||
|
||||
${result.failedTests > 0 ? `
|
||||
### Failed Tests Analysis
|
||||
${result.tests.filter(t => !t.success).map(test => `
|
||||
- **${test.name}**: ${test.error?.message || `HTTP ${test.status}`}
|
||||
`).join('')}
|
||||
|
||||
### Next Steps
|
||||
1. Check server logs for detailed error information
|
||||
2. Verify tRPC router configuration
|
||||
3. Ensure all required dependencies are installed
|
||||
4. Validate environment configuration
|
||||
5. Test endpoints manually if needed
|
||||
` : `
|
||||
### Optimization Opportunities
|
||||
1. Monitor response times for performance optimization
|
||||
2. Consider implementing caching for frequently accessed endpoints
|
||||
3. Add monitoring and alerting for endpoint health
|
||||
4. Implement rate limiting if not already in place
|
||||
`}
|
||||
|
||||
---
|
||||
*Generated at ${new Date().toISOString()}*
|
||||
`;
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const baseUrl = process.argv[2];
|
||||
const tester = new TRPCEndpointTester(baseUrl);
|
||||
|
||||
const generateReport = process.argv.includes("--report");
|
||||
const testBatch = process.argv.includes("--batch");
|
||||
const testSubscriptions = process.argv.includes("--subscriptions");
|
||||
|
||||
async function runTests() {
|
||||
// Run main endpoint tests
|
||||
const result = await tester.runEndpointTests();
|
||||
|
||||
// Run additional tests if requested
|
||||
if (testBatch) {
|
||||
const batchResult = await tester.testBatchRequests();
|
||||
result.tests.push(batchResult);
|
||||
if (!batchResult.success) {
|
||||
result.failedTests++;
|
||||
} else {
|
||||
result.passedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
if (testSubscriptions) {
|
||||
const subscriptionResult = await tester.testSubscriptions();
|
||||
result.tests.push(subscriptionResult);
|
||||
if (!subscriptionResult.success) {
|
||||
result.failedTests++;
|
||||
} else {
|
||||
result.passedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
runTests()
|
||||
.then((result) => {
|
||||
console.log('\n=== tRPC ENDPOINT TEST RESULTS ===');
|
||||
console.log(`Overall Success: ${result.success ? '✅' : '❌'}`);
|
||||
console.log(`Total Duration: ${result.totalDuration}ms`);
|
||||
console.log(`Passed Tests: ${result.passedTests}/${result.tests.length}`);
|
||||
console.log(`Failed Tests: ${result.failedTests}/${result.tests.length}`);
|
||||
console.log(`Critical Failures: ${result.criticalFailures}`);
|
||||
|
||||
console.log('\n=== INDIVIDUAL TEST RESULTS ===');
|
||||
for (const test of result.tests) {
|
||||
const status = test.success ? '✅' : '❌';
|
||||
console.log(`${status} ${test.name} (HTTP ${test.status}, ${test.duration}ms)`);
|
||||
|
||||
if (test.error) {
|
||||
console.log(` Error: ${test.error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (generateReport) {
|
||||
const report = tester.generateTestReport(result);
|
||||
const fs = require("node:fs");
|
||||
const reportPath = `trpc-test-report-${Date.now()}.md`;
|
||||
fs.writeFileSync(reportPath, report);
|
||||
console.log(`\n📋 Test report saved to: ${reportPath}`);
|
||||
}
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('tRPC endpoint tests failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
371
scripts/migration/validate-database.ts
Normal file
371
scripts/migration/validate-database.ts
Normal file
@ -0,0 +1,371 @@
|
||||
/**
|
||||
* Database Validation and Health Checks
|
||||
*
|
||||
* Comprehensive validation of database schema, data integrity,
|
||||
* and readiness for the new tRPC and batch processing architecture.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { migrationLogger } from "./migration-logger";
|
||||
|
||||
interface ValidationResult {
|
||||
success: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
metrics: Record<string, number>;
|
||||
}
|
||||
|
||||
export class DatabaseValidator {
|
||||
private prisma: PrismaClient;
|
||||
|
||||
constructor() {
|
||||
this.prisma = new PrismaClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comprehensive database validation
|
||||
*/
|
||||
async validateDatabase(): Promise<ValidationResult> {
|
||||
const result: ValidationResult = {
|
||||
success: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
try {
|
||||
migrationLogger.startStep("DATABASE_VALIDATION", "Running comprehensive database validation");
|
||||
|
||||
// Test database connection
|
||||
await this.validateConnection(result);
|
||||
|
||||
// Validate schema integrity
|
||||
await this.validateSchemaIntegrity(result);
|
||||
|
||||
// Validate data integrity
|
||||
await this.validateDataIntegrity(result);
|
||||
|
||||
// Validate indexes and performance
|
||||
await this.validateIndexes(result);
|
||||
|
||||
// Validate batch processing readiness
|
||||
await this.validateBatchProcessingReadiness(result);
|
||||
|
||||
// Validate tRPC readiness
|
||||
await this.validateTRPCReadiness(result);
|
||||
|
||||
// Collect metrics
|
||||
await this.collectMetrics(result);
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
if (result.success) {
|
||||
migrationLogger.completeStep("DATABASE_VALIDATION");
|
||||
} else {
|
||||
migrationLogger.failStep("DATABASE_VALIDATION", new Error(`Validation failed with ${result.errors.length} errors`));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.success = false;
|
||||
result.errors.push(`Database validation failed: ${(error as Error).message}`);
|
||||
migrationLogger.error("DATABASE_VALIDATION", "Critical validation error", error as Error);
|
||||
} finally {
|
||||
await this.prisma.$disconnect();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async validateConnection(result: ValidationResult): Promise<void> {
|
||||
try {
|
||||
migrationLogger.info("DB_CONNECTION", "Testing database connection");
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
migrationLogger.info("DB_CONNECTION", "Database connection successful");
|
||||
} catch (error) {
|
||||
result.errors.push(`Database connection failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSchemaIntegrity(result: ValidationResult): Promise<void> {
|
||||
migrationLogger.info("SCHEMA_VALIDATION", "Validating schema integrity");
|
||||
|
||||
try {
|
||||
// Check if all required tables exist
|
||||
const requiredTables = [
|
||||
'Company', 'User', 'Session', 'SessionImport', 'Message',
|
||||
'SessionProcessingStatus', 'Question', 'SessionQuestion',
|
||||
'AIBatchRequest', 'AIProcessingRequest', 'AIModel',
|
||||
'AIModelPricing', 'CompanyAIModel', 'PlatformUser'
|
||||
];
|
||||
|
||||
for (const table of requiredTables) {
|
||||
try {
|
||||
await this.prisma.$queryRawUnsafe(`SELECT 1 FROM "${table}" LIMIT 1`);
|
||||
} catch (error) {
|
||||
result.errors.push(`Required table missing or inaccessible: ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required enums
|
||||
const requiredEnums = [
|
||||
'ProcessingStage', 'ProcessingStatus', 'AIBatchRequestStatus',
|
||||
'AIRequestStatus', 'SentimentCategory', 'SessionCategory'
|
||||
];
|
||||
|
||||
for (const enumName of requiredEnums) {
|
||||
try {
|
||||
const enumValues = await this.prisma.$queryRawUnsafe(
|
||||
`SELECT unnest(enum_range(NULL::${enumName})) as value`
|
||||
);
|
||||
if (Array.isArray(enumValues) && enumValues.length === 0) {
|
||||
result.warnings.push(`Enum ${enumName} has no values`);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push(`Required enum missing: ${enumName}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`Schema validation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateDataIntegrity(result: ValidationResult): Promise<void> {
|
||||
migrationLogger.info("DATA_INTEGRITY", "Validating data integrity");
|
||||
|
||||
try {
|
||||
// Check for orphaned records
|
||||
const orphanedSessions = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "Session" s
|
||||
LEFT JOIN "Company" c ON s."companyId" = c.id
|
||||
WHERE c.id IS NULL
|
||||
`;
|
||||
|
||||
if (orphanedSessions[0]?.count > 0) {
|
||||
result.errors.push(`Found ${orphanedSessions[0].count} orphaned sessions`);
|
||||
}
|
||||
|
||||
// Check for sessions without processing status
|
||||
const sessionsWithoutStatus = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "Session" s
|
||||
LEFT JOIN "SessionProcessingStatus" sps ON s.id = sps."sessionId"
|
||||
WHERE sps."sessionId" IS NULL
|
||||
`;
|
||||
|
||||
if (sessionsWithoutStatus[0]?.count > 0) {
|
||||
result.warnings.push(`Found ${sessionsWithoutStatus[0].count} sessions without processing status`);
|
||||
}
|
||||
|
||||
// Check for inconsistent batch processing states
|
||||
const inconsistentBatchStates = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "AIProcessingRequest" apr
|
||||
WHERE apr."batchId" IS NOT NULL
|
||||
AND apr."processingStatus" = 'PENDING_BATCHING'
|
||||
`;
|
||||
|
||||
if (inconsistentBatchStates[0]?.count > 0) {
|
||||
result.warnings.push(`Found ${inconsistentBatchStates[0].count} requests with inconsistent batch states`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`Data integrity validation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateIndexes(result: ValidationResult): Promise<void> {
|
||||
migrationLogger.info("INDEX_VALIDATION", "Validating database indexes");
|
||||
|
||||
try {
|
||||
// Check for missing critical indexes
|
||||
const criticalIndexes = [
|
||||
{ table: 'Session', columns: ['companyId', 'startTime'] },
|
||||
{ table: 'SessionProcessingStatus', columns: ['stage', 'status'] },
|
||||
{ table: 'AIProcessingRequest', columns: ['processingStatus'] },
|
||||
{ table: 'AIBatchRequest', columns: ['companyId', 'status'] },
|
||||
];
|
||||
|
||||
for (const indexInfo of criticalIndexes) {
|
||||
const indexExists = await this.prisma.$queryRawUnsafe(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM pg_indexes
|
||||
WHERE tablename = '${indexInfo.table}'
|
||||
AND indexdef LIKE '%${indexInfo.columns.join('%')}%'
|
||||
`) as {count: string}[];
|
||||
|
||||
if (parseInt(indexExists[0]?.count || '0') === 0) {
|
||||
result.warnings.push(`Missing recommended index on ${indexInfo.table}(${indexInfo.columns.join(', ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.warnings.push(`Index validation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateBatchProcessingReadiness(result: ValidationResult): Promise<void> {
|
||||
migrationLogger.info("BATCH_READINESS", "Validating batch processing readiness");
|
||||
|
||||
try {
|
||||
// Check if AIBatchRequest table is properly configured
|
||||
const batchTableCheck = await this.prisma.$queryRaw<{count: bigint}[]>`
|
||||
SELECT COUNT(*) as count FROM "AIBatchRequest"
|
||||
`;
|
||||
|
||||
// Check if AIProcessingRequest has batch-related fields
|
||||
const batchFieldsCheck = await this.prisma.$queryRawUnsafe(`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'AIProcessingRequest'
|
||||
AND column_name IN ('processingStatus', 'batchId')
|
||||
`) as {column_name: string}[];
|
||||
|
||||
if (batchFieldsCheck.length < 2) {
|
||||
result.errors.push("AIProcessingRequest table missing batch processing fields");
|
||||
}
|
||||
|
||||
// Check if batch status enum values are correct
|
||||
const batchStatusValues = await this.prisma.$queryRawUnsafe(`
|
||||
SELECT unnest(enum_range(NULL::AIBatchRequestStatus)) as value
|
||||
`) as {value: string}[];
|
||||
|
||||
const requiredBatchStatuses = [
|
||||
'PENDING', 'UPLOADING', 'VALIDATING', 'IN_PROGRESS',
|
||||
'FINALIZING', 'COMPLETED', 'PROCESSED', 'FAILED', 'CANCELLED'
|
||||
];
|
||||
|
||||
const missingStatuses = requiredBatchStatuses.filter(
|
||||
status => !batchStatusValues.some(v => v.value === status)
|
||||
);
|
||||
|
||||
if (missingStatuses.length > 0) {
|
||||
result.errors.push(`Missing batch status values: ${missingStatuses.join(', ')}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`Batch processing readiness validation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateTRPCReadiness(result: ValidationResult): Promise<void> {
|
||||
migrationLogger.info("TRPC_READINESS", "Validating tRPC readiness");
|
||||
|
||||
try {
|
||||
// Check if all required models are accessible
|
||||
const modelTests = [
|
||||
() => this.prisma.company.findFirst(),
|
||||
() => this.prisma.user.findFirst(),
|
||||
() => this.prisma.session.findFirst(),
|
||||
() => this.prisma.aIProcessingRequest.findFirst(),
|
||||
];
|
||||
|
||||
for (const test of modelTests) {
|
||||
try {
|
||||
await test();
|
||||
} catch (error) {
|
||||
result.warnings.push(`Prisma model access issue: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test complex queries that tRPC will use
|
||||
try {
|
||||
await this.prisma.session.findMany({
|
||||
where: { companyId: 'test' },
|
||||
include: {
|
||||
messages: true,
|
||||
processingStatus: true,
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
// This is expected to fail with the test companyId, but should not error on structure
|
||||
if (!(error as Error).message.includes('test')) {
|
||||
result.warnings.push(`Complex query structure issue: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.warnings.push(`tRPC readiness validation failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async collectMetrics(result: ValidationResult): Promise<void> {
|
||||
migrationLogger.info("METRICS_COLLECTION", "Collecting database metrics");
|
||||
|
||||
try {
|
||||
// Count records in key tables
|
||||
const companiesCount = await this.prisma.company.count();
|
||||
const usersCount = await this.prisma.user.count();
|
||||
const sessionsCount = await this.prisma.session.count();
|
||||
const messagesCount = await this.prisma.message.count();
|
||||
const batchRequestsCount = await this.prisma.aIBatchRequest.count();
|
||||
const processingRequestsCount = await this.prisma.aIProcessingRequest.count();
|
||||
|
||||
result.metrics = {
|
||||
companies: companiesCount,
|
||||
users: usersCount,
|
||||
sessions: sessionsCount,
|
||||
messages: messagesCount,
|
||||
batchRequests: batchRequestsCount,
|
||||
processingRequests: processingRequestsCount,
|
||||
};
|
||||
|
||||
// Check processing status distribution
|
||||
const processingStatusCounts = await this.prisma.sessionProcessingStatus.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
});
|
||||
|
||||
for (const statusCount of processingStatusCounts) {
|
||||
result.metrics[`processing_${statusCount.status.toLowerCase()}`] = statusCount._count.status;
|
||||
}
|
||||
|
||||
// Check batch request status distribution
|
||||
const batchStatusCounts = await this.prisma.aIBatchRequest.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
});
|
||||
|
||||
for (const statusCount of batchStatusCounts) {
|
||||
result.metrics[`batch_${statusCount.status.toLowerCase()}`] = statusCount._count.status;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
result.warnings.push(`Metrics collection failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const validator = new DatabaseValidator();
|
||||
|
||||
validator.validateDatabase()
|
||||
.then((result) => {
|
||||
console.log('\n=== DATABASE VALIDATION RESULTS ===');
|
||||
console.log(`Success: ${result.success ? '✅' : '❌'}`);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log('\n❌ ERRORS:');
|
||||
result.errors.forEach(error => console.log(` - ${error}`));
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
console.log('\n⚠️ WARNINGS:');
|
||||
result.warnings.forEach(warning => console.log(` - ${warning}`));
|
||||
}
|
||||
|
||||
console.log('\n📊 METRICS:');
|
||||
Object.entries(result.metrics).forEach(([key, value]) => {
|
||||
console.log(` ${key}: ${value}`);
|
||||
});
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Validation failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
241
scripts/test-security-headers.ts
Normal file
241
scripts/test-security-headers.ts
Normal file
@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Security Headers Testing Script
|
||||
*
|
||||
* This script tests HTTP security headers on a running Next.js server.
|
||||
* Run this against your development or production server to verify
|
||||
* that security headers are properly configured.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/test-security-headers.ts [url]
|
||||
*
|
||||
* Examples:
|
||||
* pnpm exec tsx scripts/test-security-headers.ts http://localhost:3000
|
||||
* pnpm exec tsx scripts/test-security-headers.ts https://your-domain.com
|
||||
*/
|
||||
|
||||
interface SecurityHeader {
|
||||
name: string;
|
||||
expectedValue?: string;
|
||||
description: string;
|
||||
critical: boolean;
|
||||
}
|
||||
|
||||
const SECURITY_HEADERS: SecurityHeader[] = [
|
||||
{
|
||||
name: "X-Content-Type-Options",
|
||||
expectedValue: "nosniff",
|
||||
description: "Prevents MIME type sniffing attacks",
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "X-Frame-Options",
|
||||
expectedValue: "DENY",
|
||||
description: "Prevents clickjacking attacks",
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "X-XSS-Protection",
|
||||
expectedValue: "1; mode=block",
|
||||
description: "Enables XSS protection in legacy browsers",
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
name: "Referrer-Policy",
|
||||
expectedValue: "strict-origin-when-cross-origin",
|
||||
description: "Controls referrer information sent with requests",
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
name: "X-DNS-Prefetch-Control",
|
||||
expectedValue: "off",
|
||||
description: "Prevents DNS rebinding attacks",
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
name: "Content-Security-Policy",
|
||||
description: "Prevents code injection attacks",
|
||||
critical: true,
|
||||
},
|
||||
{
|
||||
name: "Permissions-Policy",
|
||||
description: "Controls browser feature access",
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
name: "Strict-Transport-Security",
|
||||
description: "Enforces HTTPS (production only)",
|
||||
critical: false,
|
||||
},
|
||||
];
|
||||
|
||||
const CSP_DIRECTIVES = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https:",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"object-src 'none'",
|
||||
"upgrade-insecure-requests",
|
||||
];
|
||||
|
||||
const PERMISSIONS_POLICIES = [
|
||||
"camera=()",
|
||||
"microphone=()",
|
||||
"geolocation=()",
|
||||
"interest-cohort=()",
|
||||
"browsing-topics=()",
|
||||
];
|
||||
|
||||
async function testSecurityHeaders(url: string): Promise<void> {
|
||||
console.log(`🔍 Testing security headers for: ${url}\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "HEAD", // Use HEAD to avoid downloading the full response body
|
||||
});
|
||||
|
||||
console.log(`📊 Response Status: ${response.status} ${response.statusText}\n`);
|
||||
|
||||
let criticalMissing = 0;
|
||||
let warningCount = 0;
|
||||
|
||||
for (const header of SECURITY_HEADERS) {
|
||||
const value = response.headers.get(header.name);
|
||||
|
||||
if (!value) {
|
||||
const status = header.critical ? "❌ CRITICAL" : "⚠️ WARNING";
|
||||
console.log(`${status} Missing: ${header.name}`);
|
||||
console.log(` Description: ${header.description}\n`);
|
||||
|
||||
if (header.critical) criticalMissing++;
|
||||
else warningCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.expectedValue && value !== header.expectedValue) {
|
||||
const status = header.critical ? "❌ CRITICAL" : "⚠️ WARNING";
|
||||
console.log(`${status} Incorrect: ${header.name}`);
|
||||
console.log(` Expected: ${header.expectedValue}`);
|
||||
console.log(` Actual: ${value}`);
|
||||
console.log(` Description: ${header.description}\n`);
|
||||
|
||||
if (header.critical) criticalMissing++;
|
||||
else warningCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`✅ OK: ${header.name}`);
|
||||
console.log(` Value: ${value}`);
|
||||
console.log(` Description: ${header.description}\n`);
|
||||
}
|
||||
|
||||
// Detailed CSP analysis
|
||||
const csp = response.headers.get("Content-Security-Policy");
|
||||
if (csp) {
|
||||
console.log("🔒 Content Security Policy Analysis:");
|
||||
|
||||
let cspIssues = 0;
|
||||
for (const directive of CSP_DIRECTIVES) {
|
||||
if (csp.includes(directive)) {
|
||||
console.log(` ✅ ${directive}`);
|
||||
} else {
|
||||
console.log(` ❌ Missing: ${directive}`);
|
||||
cspIssues++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cspIssues > 0) {
|
||||
console.log(` ⚠️ ${cspIssues} CSP directive(s) missing or incorrect\n`);
|
||||
warningCount += cspIssues;
|
||||
} else {
|
||||
console.log(` ✅ All CSP directives present\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Detailed Permissions Policy analysis
|
||||
const permissionsPolicy = response.headers.get("Permissions-Policy");
|
||||
if (permissionsPolicy) {
|
||||
console.log("🔐 Permissions Policy Analysis:");
|
||||
|
||||
let policyIssues = 0;
|
||||
for (const policy of PERMISSIONS_POLICIES) {
|
||||
if (permissionsPolicy.includes(policy)) {
|
||||
console.log(` ✅ ${policy}`);
|
||||
} else {
|
||||
console.log(` ❌ Missing: ${policy}`);
|
||||
policyIssues++;
|
||||
}
|
||||
}
|
||||
|
||||
if (policyIssues > 0) {
|
||||
console.log(` ⚠️ ${policyIssues} permission policy(ies) missing\n`);
|
||||
warningCount += policyIssues;
|
||||
} else {
|
||||
console.log(` ✅ All permission policies present\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// HSTS environment check
|
||||
const hsts = response.headers.get("Strict-Transport-Security");
|
||||
const isHttps = url.startsWith("https://");
|
||||
|
||||
if (isHttps && !hsts) {
|
||||
console.log("⚠️ WARNING: HTTPS site missing HSTS header");
|
||||
console.log(" Consider adding Strict-Transport-Security for production\n");
|
||||
warningCount++;
|
||||
} else if (hsts && !isHttps) {
|
||||
console.log("ℹ️ INFO: HSTS header present on HTTP site (will be ignored by browsers)\n");
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("=" .repeat(60));
|
||||
console.log("📋 SECURITY HEADERS SUMMARY");
|
||||
console.log("=" .repeat(60));
|
||||
|
||||
if (criticalMissing === 0 && warningCount === 0) {
|
||||
console.log("🎉 EXCELLENT: All security headers are properly configured!");
|
||||
} else if (criticalMissing === 0) {
|
||||
console.log(`✅ GOOD: No critical issues found`);
|
||||
console.log(`⚠️ ${warningCount} warning(s) - consider addressing these for optimal security`);
|
||||
} else {
|
||||
console.log(`❌ ISSUES FOUND:`);
|
||||
console.log(` Critical: ${criticalMissing}`);
|
||||
console.log(` Warnings: ${warningCount}`);
|
||||
console.log(`\n🔧 Please address critical issues before deploying to production`);
|
||||
}
|
||||
|
||||
// Additional recommendations
|
||||
console.log("\n💡 ADDITIONAL RECOMMENDATIONS:");
|
||||
console.log("• Regularly test headers with online tools like securityheaders.com");
|
||||
console.log("• Monitor CSP violations in production to fine-tune policies");
|
||||
console.log("• Consider implementing HSTS preloading for production domains");
|
||||
console.log("• Review and update security headers based on new threats");
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error testing headers: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const url = process.argv[2] || "http://localhost:3000";
|
||||
|
||||
console.log("🛡️ Security Headers Testing Tool");
|
||||
console.log("=" .repeat(60));
|
||||
|
||||
await testSecurityHeaders(url);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error("Script failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user