From 599b521d7c0e930a537f0695f18cebb4453d9a3f Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 30 Jun 2026 12:27:47 +0200 Subject: [PATCH 1/2] ci(db): improve production migration logging --- .github/workflows/deploy-production.yml | 2 +- package.json | 1 + packages/db/src/run-migrations.ts | 197 ++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 packages/db/src/run-migrations.ts diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index c8a6a5e5a8..f10237f0bc 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -75,7 +75,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run Drizzle migrations - run: NODE_ENV=production pnpm run drizzle migrate + run: NODE_ENV=production pnpm run drizzle:migrate stage-app: uses: ./.github/workflows/stage-vercel-deployment.yml diff --git a/package.json b/package.json index d4a653ce26..04c9197d0c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format:changed": "git diff --name-only $(git merge-base origin/main HEAD) --diff-filter=ACMR -- '**/*.js' '**/*.jsx' '**/*.ts' '**/*.tsx' '**/*.json' '**/*.css' '**/*.md' | xargs -r oxfmt --no-error-on-unmatched-pattern", "validate": "pnpm run typecheck && pnpm run lint && pnpm run test", "drizzle": "pnpm --filter @kilocode/db exec drizzle-kit", + "drizzle:migrate": "tsx packages/db/src/run-migrations.ts", "drizzle:verify-bootstrap": "bash scripts/verify-drizzle-bootstrap.sh", "test:e2e": "pnpm --filter web run test:e2e", "dependency-cycle-check": "pnpm --filter web run dependency-cycle-check", diff --git a/packages/db/src/run-migrations.ts b/packages/db/src/run-migrations.ts new file mode 100644 index 0000000000..f57a45011b --- /dev/null +++ b/packages/db/src/run-migrations.ts @@ -0,0 +1,197 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; +import { Pool } from 'pg'; +import { z } from 'zod'; +import { readMigrationFiles } from 'drizzle-orm/migrator'; +import { computeDatabaseUrl, getDatabaseClientConfig } from './database-url'; + +const migrationsSchema = 'drizzle'; +const migrationsTable = '__drizzle_migrations'; +const migrationsFolder = fileURLToPath(new URL('./migrations', import.meta.url)); + +const JournalSchema = z.object({ + entries: z.array( + z.object({ + tag: z.string(), + when: z.number(), + }) + ), +}); + +type Migration = { + tag: string; + folderMillis: number; + hash: string; + sql: string[]; +}; + +type MigrationRow = { + created_at: number | string | null; +}; + +dotenv.config({ path: fileURLToPath(new URL('../../../.env.local', import.meta.url)), quiet: true }); + +function quoteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function getStringField(value: unknown, key: string): string | undefined { + if (!isRecord(value)) { + return undefined; + } + + const field = value[key]; + return typeof field === 'string' && field.length > 0 ? field : undefined; +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + const lines = [error.stack ?? error.message]; + const postgresFields = [ + 'code', + 'severity', + 'detail', + 'hint', + 'position', + 'where', + 'schema', + 'table', + 'column', + 'constraint', + 'routine', + ]; + + for (const field of postgresFields) { + const value = getStringField(error, field); + if (value) { + lines.push(`${field}: ${value}`); + } + } + + return lines.join('\n'); + } + + return `Thrown value: ${String(error)}`; +} + +async function readMigrations(): Promise { + const journalPath = new URL('./migrations/meta/_journal.json', import.meta.url); + const journal = JournalSchema.parse(JSON.parse(await readFile(journalPath, 'utf8'))); + const migrationFiles = readMigrationFiles({ + migrationsFolder, + migrationsSchema, + migrationsTable, + }); + + return journal.entries.map((entry, index) => { + const migration = migrationFiles[index]; + if (!migration) { + throw new Error(`Missing migration metadata for journal entry ${entry.tag}`); + } + + return { + tag: entry.tag, + folderMillis: migration.folderMillis, + hash: migration.hash, + sql: migration.sql, + }; + }); +} + +function getLastMigrationMillis(row: MigrationRow | undefined): number | undefined { + if (!row || row.created_at === null) { + return undefined; + } + + const createdAt = Number(row.created_at); + if (!Number.isFinite(createdAt)) { + throw new Error(`Unexpected latest migration timestamp: ${String(row.created_at)}`); + } + + return createdAt; +} + +async function main(): Promise { + const migrations = await readMigrations(); + const pool = new Pool({ ...getDatabaseClientConfig(computeDatabaseUrl()), max: 1 }); + const client = await pool.connect(); + + try { + const quotedSchema = quoteIdentifier(migrationsSchema); + const quotedTable = quoteIdentifier(migrationsTable); + const migrationLogTable = `${quotedSchema}.${quotedTable}`; + + await client.query(`CREATE SCHEMA IF NOT EXISTS ${quotedSchema}`); + await client.query(` + CREATE TABLE IF NOT EXISTS ${migrationLogTable} ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at bigint + ) + `); + + const latestMigration = await client.query( + `SELECT created_at FROM ${migrationLogTable} ORDER BY created_at DESC LIMIT 1` + ); + const lastMigrationMillis = getLastMigrationMillis(latestMigration.rows[0]); + const pendingMigrations = migrations.filter( + migration => lastMigrationMillis === undefined || lastMigrationMillis < migration.folderMillis + ); + + console.log(`Loaded ${migrations.length} migrations from ${migrationsFolder}`); + console.log(`Found ${pendingMigrations.length} pending migrations`); + + if (pendingMigrations.length === 0) { + return; + } + + await client.query('BEGIN'); + try { + for (const migration of pendingMigrations) { + console.log(`::group::Applying migration ${migration.tag}`); + try { + console.log( + `Migration timestamp ${migration.folderMillis}, sha256 ${migration.hash.slice(0, 12)}` + ); + + for (const [index, statement] of migration.sql.entries()) { + console.log(`Executing statement ${index + 1}/${migration.sql.length}`); + console.log(statement.trim()); + await client.query(statement); + } + + await client.query( + `INSERT INTO ${migrationLogTable} (hash, created_at) VALUES ($1, $2)`, + [migration.hash, migration.folderMillis] + ); + console.log(`Applied migration ${migration.tag}`); + } catch (error) { + console.error(`Failed migration ${migration.tag}`); + console.error(formatError(error)); + throw error; + } finally { + console.log('::endgroup::'); + } + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + } finally { + client.release(); + await pool.end(); + } +} + +main().catch(error => { + console.error('Drizzle migrations failed'); + console.error(formatError(error)); + process.exitCode = 1; +}); From a11cd4c1edbf8d98570d512b85b76cee8bc78392 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 30 Jun 2026 13:06:16 +0200 Subject: [PATCH 2/2] ci(db): gate per-statement migration logging behind env var --- .github/workflows/deploy-production.yml | 2 ++ packages/db/src/run-migrations.ts | 36 ++++++++++++++++++------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index f10237f0bc..87a7af030b 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -75,6 +75,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Run Drizzle migrations + env: + DRIZZLE_MIGRATION_LOGGING: verbose run: NODE_ENV=production pnpm run drizzle:migrate stage-app: diff --git a/packages/db/src/run-migrations.ts b/packages/db/src/run-migrations.ts index f57a45011b..4f033f5b95 100644 --- a/packages/db/src/run-migrations.ts +++ b/packages/db/src/run-migrations.ts @@ -103,6 +103,13 @@ async function readMigrations(): Promise { }); } +// Per-statement success logging is opt-in (we enable it in CI via the workflow). +// Failure diagnostics are always printed regardless of this flag. +function isStatementLoggingEnabled(): boolean { + const value = process.env.DRIZZLE_MIGRATION_LOGGING?.trim().toLowerCase(); + return value !== undefined && value !== '' && value !== '0' && value !== 'false'; +} + function getLastMigrationMillis(row: MigrationRow | undefined): number | undefined { if (!row || row.created_at === null) { return undefined; @@ -117,6 +124,7 @@ function getLastMigrationMillis(row: MigrationRow | undefined): number | undefin } async function main(): Promise { + const logStatements = isStatementLoggingEnabled(); const migrations = await readMigrations(); const pool = new Pool({ ...getDatabaseClientConfig(computeDatabaseUrl()), max: 1 }); const client = await pool.connect(); @@ -153,15 +161,20 @@ async function main(): Promise { await client.query('BEGIN'); try { for (const migration of pendingMigrations) { - console.log(`::group::Applying migration ${migration.tag}`); - try { - console.log( - `Migration timestamp ${migration.folderMillis}, sha256 ${migration.hash.slice(0, 12)}` - ); + console.log(`Applying migration ${migration.tag}`); + let lastStatementIndex = 0; + let lastStatement = ''; + try { for (const [index, statement] of migration.sql.entries()) { - console.log(`Executing statement ${index + 1}/${migration.sql.length}`); - console.log(statement.trim()); + lastStatementIndex = index; + lastStatement = statement; + + if (logStatements) { + console.log(` statement ${index + 1}/${migration.sql.length}:`); + console.log(statement.trim()); + } + await client.query(statement); } @@ -171,11 +184,14 @@ async function main(): Promise { ); console.log(`Applied migration ${migration.tag}`); } catch (error) { - console.error(`Failed migration ${migration.tag}`); + // Always surface which statement failed and the full Postgres error, + // even when per-statement success logging is disabled. + console.error( + `Failed migration ${migration.tag} at statement ${lastStatementIndex + 1}/${migration.sql.length}:` + ); + console.error(lastStatement.trim()); console.error(formatError(error)); throw error; - } finally { - console.log('::endgroup::'); } }