diff --git a/.forgejo/workflows/database-validation.yml b/.forgejo/workflows/database-validation.yml index 604d22a..910692e 100644 --- a/.forgejo/workflows/database-validation.yml +++ b/.forgejo/workflows/database-validation.yml @@ -1,146 +1,65 @@ -# Database Schema Validation +# Database Migration Verification # -# Validates that schema snapshots are in sync with migrations and entity definitions. -# Runs on every PR that touches migrations, entities, or schema files. +# Verifies all squashed migrations apply cleanly against a fresh PostgreSQL instance. +# Optionally tests down() rollback for each feature. # -# This is Layer 2 enforcement (mandatory, cannot be bypassed). -# Layer 1 is git hooks (can be bypassed with --no-verify). +# Triggers on migration file changes or manual dispatch. -name: Database Schema Validation +name: Database Migration Verification on: pull_request: paths: - 'codebase/features/*/backend-api/src/migrations/**' - - 'codebase/features/*/database/schema.sql' - - 'codebase/features/*/backend-api/src/entities/**' + - 'codebase/features/*/backend-api/src/database/migrations/**' + - 'codebase/features/*/backend-api/migrations/**' + - 'codebase/features/*/database/migrations/**' + - 'tools/verify-migrations.ts' workflow_dispatch: env: CI: true + PG_HOST: localhost + PG_PORT: '5432' + PG_USER: testuser + PG_PASSWORD: testpass + PG_ADMIN_DB: postgres jobs: - # Matrix job: validate all features with databases in parallel - validate-schema: - name: Validate ${{ matrix.feature }} + verify-migrations: + name: Verify All Migrations runs-on: ubuntu-latest - timeout-minutes: 15 - strategy: - fail-fast: false # Show all failures, not just first - matrix: - feature: - - analytics - - conversation-assistant - - seo - - marketplace - - email - - status-dashboard - - feature-flags - - image-generator - - platform-admin - - sso - - landing - - payments - - profile - - attributes - - merchant + timeout-minutes: 20 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - - name: Checkout repository + - name: Checkout uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 with: - node-version: '22' - - - name: Setup pnpm - uses: pnpm/action-setup@v2 - with: - version: 9 + bun-version: '1.2.6' - name: Install dependencies - working-directory: codebase - run: pnpm install --frozen-lockfile + run: bun install --frozen-lockfile - - name: Check if feature has database - id: check-db - run: | - if [ -f "codebase/features/${{ matrix.feature }}/docker-compose.yml" ]; then - echo "has_db=true" >> $GITHUB_OUTPUT - else - echo "has_db=false" >> $GITHUB_OUTPUT - fi + - name: Build backend features + run: bunx turbo run build --filter='./codebase/features/*/backend-api' - - name: Start test database - if: steps.check-db.outputs.has_db == 'true' - working-directory: codebase/features/${{ matrix.feature }} - run: | - docker-compose up -d - sleep 10 # Wait for database to be ready - docker-compose ps - - - name: Run migrations - if: steps.check-db.outputs.has_db == 'true' - working-directory: codebase/features/${{ matrix.feature }}/backend-api - run: | - if [ -f "src/data-source.ts" ]; then - pnpm migration:run || echo "No migrations to run" - else - echo "⚠️ No data-source.ts found, skipping migrations" - fi - - - name: Generate fresh snapshot - if: steps.check-db.outputs.has_db == 'true' - run: | - pnpm db:snapshot ${{ matrix.feature }} || { - echo "⚠️ Snapshot generation failed, skipping validation" - exit 0 - } - - - name: Check for schema drift - if: steps.check-db.outputs.has_db == 'true' - run: | - SCHEMA_FILE="codebase/features/${{ matrix.feature }}/database/schema.sql" - - if [ ! -f "$SCHEMA_FILE" ]; then - echo "⚠️ No schema.sql file exists yet, skipping validation" - exit 0 - fi - - if git diff --exit-code "$SCHEMA_FILE"; then - echo "✅ Schema snapshot in sync" - else - echo "❌ Schema snapshot out of sync with migrations!" - echo "" - echo "The committed schema.sql doesn't match the database after running migrations." - echo "" - echo "To fix:" - echo " 1. Run: pnpm db:migrate:run ${{ matrix.feature }}" - echo " 2. Commit the updated schema.sql file" - echo "" - echo "Diff:" - git diff "$SCHEMA_FILE" - exit 1 - fi - - - name: Cleanup - if: always() && steps.check-db.outputs.has_db == 'true' - working-directory: codebase/features/${{ matrix.feature }} - run: | - docker-compose down -v || true - - # Summary job: requires all matrix jobs to pass - validation-complete: - name: All Features Validated - needs: validate-schema - runs-on: ubuntu-latest - if: always() - steps: - - name: Check validation results - run: | - if [ "${{ needs.validate-schema.result }}" != "success" ]; then - echo "❌ Schema validation failed for one or more features" - exit 1 - fi - echo "✅ All database schemas validated successfully" + - name: Verify migrations + run: bun run tools/verify-migrations.ts --test-down diff --git a/tools/verify-migrations.ts b/tools/verify-migrations.ts index 40a6617..fd87d1a 100644 --- a/tools/verify-migrations.ts +++ b/tools/verify-migrations.ts @@ -6,18 +6,34 @@ * 1. Creating a temporary database * 2. Running the migration's up() method via TypeORM * 3. Verifying tables/enums/indexes were created - * 4. Dropping the temporary database + * 4. Optionally testing down() rollback (--test-down) + * 5. Dropping the temporary database * - * Uses a running postgres instance (streaming on port 25468) as the test host. + * Configuration via environment variables (defaults to streaming dev container): + * PG_HOST - PostgreSQL host (default: 127.0.0.1) + * PG_PORT - PostgreSQL port (default: 25468) + * PG_USER - PostgreSQL user (default: streaming) + * PG_PASSWORD - PostgreSQL password (default: devpassword) + * PG_ADMIN_DB - Admin database name (default: postgres) + * + * Flags: + * --build - Build stale backend-api packages before verification + * --test-down - Test migration rollback after up() verification */ import { DataSource } from 'typeorm'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; -const PG_HOST = '127.0.0.1'; -const PG_PORT = 25468; -const PG_USER = 'streaming'; -const PG_PASS = 'devpassword'; -const PG_ADMIN_DB = 'postgres'; +const PG_HOST = process.env.PG_HOST ?? '127.0.0.1'; +const PG_PORT = parseInt(process.env.PG_PORT ?? '25468', 10); +const PG_USER = process.env.PG_USER ?? 'streaming'; +const PG_PASS = process.env.PG_PASSWORD ?? 'devpassword'; +const PG_ADMIN_DB = process.env.PG_ADMIN_DB ?? 'postgres'; + +const cliArgs = process.argv.slice(2); +const shouldBuild = cliArgs.includes('--build'); +const testDown = cliArgs.includes('--test-down'); const FEATURES_BASE = `${import.meta.dirname}/../codebase/features`; @@ -57,6 +73,8 @@ interface VerifyResult { tables: string[]; enums: string[]; error?: string; + downSuccess?: boolean; + downError?: string; } function log(message: string): void { @@ -67,6 +85,55 @@ function logLine(message: string): void { process.stdout.write(`${message}\n`); } +function getNewestFileMtime(dir: string): number { + if (!existsSync(dir)) return 0; + let newest = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isFile()) { + const mtime = statSync(join(dir, entry.name)).mtimeMs; + if (mtime > newest) newest = mtime; + } + } + return newest; +} + +async function buildIfStale(config: FeatureConfig): Promise { + const distIdx = config.migrationGlob.indexOf('/dist/'); + const backendApiRelDir = config.migrationGlob.substring(0, distIdx); + const fullBackendPath = join(FEATURES_BASE, backendApiRelDir); + + if (!existsSync(fullBackendPath)) { + logLine(` ${backendApiRelDir}: directory not found, skipping`); + return false; + } + + const distMigDir = join(FEATURES_BASE, config.migrationGlob.replace('/*-*.js', '')); + const srcMigDir = distMigDir.replace('/dist/', '/src/'); + + const srcNewest = getNewestFileMtime(srcMigDir); + const distNewest = getNewestFileMtime(distMigDir); + + if (distNewest > 0 && distNewest >= srcNewest) { + return true; + } + + log(` ${backendApiRelDir}... `); + const proc = Bun.spawn(['bun', 'run', 'build'], { + cwd: fullBackendPath, + stdout: 'pipe', + stderr: 'pipe', + }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + logLine('FAILED'); + logLine(` ${stderr.trim().split('\n')[0]}`); + return false; + } + logLine('done'); + return true; +} + async function getAdminConnection(): Promise { const ds = new DataSource({ type: 'postgres', @@ -160,12 +227,39 @@ async function verifyFeature(admin: DataSource, config: FeatureConfig): Promise< } } - await featureDs.runMigrations(); + const applied = await featureDs.runMigrations(); const tables = await queryTables(featureDs); const enums = await queryEnums(featureDs); - return { feature: config.name, success: true, tables, enums }; + let downSuccess: boolean | undefined; + let downError: string | undefined; + + if (testDown) { + try { + for (let i = 0; i < applied.length; i++) { + await featureDs.undoLastMigration(); + } + + const remainingTables = await queryTables(featureDs); + const remainingEnums = await queryEnums(featureDs); + + if (remainingTables.length > 0 || remainingEnums.length > 0) { + downSuccess = false; + const leftover: string[] = []; + if (remainingTables.length > 0) leftover.push(`${remainingTables.length} tables`); + if (remainingEnums.length > 0) leftover.push(`${remainingEnums.length} enums`); + downError = `Rollback incomplete: ${leftover.join(', ')} remain`; + } else { + downSuccess = true; + } + } catch (error) { + downSuccess = false; + downError = error instanceof Error ? error.message : String(error); + } + } + + return { feature: config.name, success: true, tables, enums, downSuccess, downError }; } catch (error) { return { feature: config.name, @@ -232,6 +326,22 @@ async function verifySqlMigration(admin: DataSource, name: string, sqlPath: stri async function main(): Promise { logLine('=== Migration Verification ===\n'); + logLine(`PostgreSQL: ${PG_USER}@${PG_HOST}:${PG_PORT}/${PG_ADMIN_DB}`); + if (testDown) logLine('Down migration testing: enabled'); + logLine(''); + + if (shouldBuild) { + logLine('Building stale backend-api packages...'); + let buildFailures = 0; + for (const config of features) { + const ok = await buildIfStale(config); + if (!ok) buildFailures++; + } + if (buildFailures > 0) { + logLine(`\n${buildFailures} feature(s) failed to build`); + } + logLine(''); + } const admin = await getAdminConnection(); const results: VerifyResult[] = []; @@ -242,7 +352,13 @@ async function main(): Promise { results.push(result); if (result.success) { - logLine(`\x1b[32m✓\x1b[0m ${result.tables.length} tables, ${result.enums.length} enums`); + let status = `\x1b[32m✓\x1b[0m ${result.tables.length} tables, ${result.enums.length} enums`; + if (testDown) { + status += result.downSuccess + ? ` | down \x1b[32m✓\x1b[0m` + : ` | down \x1b[31m✗\x1b[0m ${result.downError}`; + } + logLine(status); } else { logLine(`\x1b[31m✗\x1b[0m ${result.error}`); } @@ -260,7 +376,9 @@ async function main(): Promise { results.push(result); if (result.success) { - logLine(`\x1b[32m✓\x1b[0m ${result.tables.length} tables, ${result.enums.length} enums`); + let status = `\x1b[32m✓\x1b[0m ${result.tables.length} tables, ${result.enums.length} enums`; + if (testDown) status += ` | down \x1b[33m-\x1b[0m (SQL, no rollback)`; + logLine(status); } else { logLine(`\x1b[31m✗\x1b[0m ${result.error}`); } @@ -274,6 +392,13 @@ async function main(): Promise { logLine(`\n=== Results ===`); logLine(`Passed: ${passed.length}/${results.length}`); + if (testDown) { + const downPassed = results.filter((r) => r.downSuccess === true); + const downFailed = results.filter((r) => r.downSuccess === false); + const downSkipped = results.filter((r) => r.success && r.downSuccess === undefined); + logLine(`Down: ${downPassed.length} passed, ${downFailed.length} failed, ${downSkipped.length} skipped`); + } + if (failed.length > 0) { logLine(`\nFailed features:`); for (const f of failed) { @@ -281,6 +406,16 @@ async function main(): Promise { } } + if (testDown) { + const downFailed = results.filter((r) => r.downSuccess === false); + if (downFailed.length > 0) { + logLine(`\nFailed rollbacks:`); + for (const f of downFailed) { + logLine(` - ${f.feature}: ${f.downError}`); + } + } + } + logLine(`\n=== Table Details ===`); for (const r of passed) { logLine(`\n${r.feature}:`); @@ -290,7 +425,8 @@ async function main(): Promise { } } - process.exit(failed.length > 0 ? 1 : 0); + const hasDownFailures = testDown && results.some((r) => r.downSuccess === false); + process.exit(failed.length > 0 || hasDownFailures ? 1 : 0); } main().catch((err: unknown) => {