diff --git a/nuxt.config.ts b/nuxt.config.ts index 5de5db0..64390c0 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -24,6 +24,10 @@ export default defineNuxtConfig({ tasks: true, }, modules: ['nitro-graphql'], + serverAssets: [{ + baseName: 'migrations', + dir: './database/migrations', // Relative to Nitro srcDir (server/) + }], graphql: { framework: 'graphql-yoga', codegen: { diff --git a/server/api/cron/migrate.get.ts b/server/api/cron/migrate.get.ts new file mode 100644 index 0000000..84f7e93 --- /dev/null +++ b/server/api/cron/migrate.get.ts @@ -0,0 +1,22 @@ +import type { MigrationResult } from '../../utils/migrate' + +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig() + + // Security check - require CRON_SECRET for production + const authHeader = getHeader(event, 'authorization') + if (config.cronSecret && authHeader !== `Bearer ${config.cronSecret}`) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + const { result } = await runTask('db:migrate') as { result: MigrationResult } + + if (!result.success) { + throw createError({ + statusCode: 500, + message: result.message, + }) + } + + return { success: true, result } +}) diff --git a/server/plugins/00.database.ts b/server/plugins/00.database.ts new file mode 100644 index 0000000..2d25e25 --- /dev/null +++ b/server/plugins/00.database.ts @@ -0,0 +1,32 @@ +import { runMigrations } from '../utils/migrate' + +export default defineNitroPlugin(async () => { + // Skip in test environment + if (process.env.NODE_ENV === 'test') { + console.log('[Database] Skipping migrations in test environment') + return + } + + // Skip if AUTO_MIGRATE is explicitly disabled + if (process.env.AUTO_MIGRATE === 'false') { + console.log('[Database] Auto-migration disabled') + return + } + + const result = await runMigrations() + + if (result.success) { + if (result.applied.length > 0) { + for (const migration of result.applied) { + console.log(`[Database] Applied migration: ${migration}`) + } + console.log(`[Database] ${result.applied.length} migration(s) applied successfully`) + } + else { + console.log('[Database] All migrations already applied') + } + } + else { + console.error('[Database] Migration failed:', result.message) + } +}) diff --git a/server/tasks/db/migrate.ts b/server/tasks/db/migrate.ts new file mode 100644 index 0000000..e72d782 --- /dev/null +++ b/server/tasks/db/migrate.ts @@ -0,0 +1,12 @@ +import { runMigrations } from '../../utils/migrate' + +export default defineTask({ + meta: { + name: 'db:migrate', + description: 'Run database migrations', + }, + async run() { + const result = await runMigrations() + return { result } + }, +}) diff --git a/server/utils/migrate.ts b/server/utils/migrate.ts new file mode 100644 index 0000000..c99e4b4 --- /dev/null +++ b/server/utils/migrate.ts @@ -0,0 +1,93 @@ +import { sql } from 'drizzle-orm' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' + +interface MigrationJournal { + version: string + dialect: string + entries: { + idx: number + version: string + when: number + tag: string + breakpoints: boolean + }[] +} + +export interface MigrationResult { + success: boolean + message: string + applied: string[] +} + +export async function runMigrations(): Promise { + const connectionString = process.env.DATABASE_URL || 'postgresql://localhost:5432/nitroping' + const client = postgres(connectionString, { max: 1 }) + const db = drizzle(client) + + const applied: string[] = [] + + try { + const storage = useStorage('assets:migrations') + + const journal = await storage.getItem('meta/_journal.json') + if (!journal || !journal.entries) { + await client.end() + return { success: true, message: 'No migrations found', applied: [] } + } + + await db.execute(sql` + CREATE TABLE IF NOT EXISTS "__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ) + `) + + const existingMigrations = await db.execute<{ hash: string }>(sql`SELECT hash FROM "__drizzle_migrations"`) + const appliedHashes = new Set(existingMigrations.map(r => r.hash)) + + for (const entry of journal.entries) { + if (appliedHashes.has(entry.tag)) { + continue + } + + const sqlContent = await storage.getItem(`${entry.tag}.sql`) + if (!sqlContent) { + applied.push(`[SKIP] ${entry.tag} - file not found`) + continue + } + + const statements = sqlContent.split('--> statement-breakpoint') + for (const stmt of statements) { + const trimmed = stmt.trim() + if (trimmed) { + await db.execute(sql.raw(trimmed)) + } + } + + await db.execute(sql` + INSERT INTO "__drizzle_migrations" (hash, created_at) + VALUES (${entry.tag}, ${Date.now()}) + `) + + applied.push(entry.tag) + } + + return { + success: true, + message: applied.length > 0 ? `Applied ${applied.length} migration(s)` : 'All migrations already applied', + applied, + } + } + catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Migration failed', + applied, + } + } + finally { + await client.end() + } +}