Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ jobs:
run: pnpm install --frozen-lockfile

- name: Run Drizzle migrations
run: NODE_ENV=production pnpm run drizzle migrate
env:
DRIZZLE_MIGRATION_LOGGING: verbose
run: NODE_ENV=production pnpm run drizzle:migrate

stage-app:
uses: ./.github/workflows/stage-vercel-deployment.yml
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
213 changes: 213 additions & 0 deletions packages/db/src/run-migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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<string, unknown> {
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<Migration[]> {
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,
};
});
}

// 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;
}

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<void> {
const logStatements = isStatementLoggingEnabled();
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<MigrationRow>(
`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(`Applying migration ${migration.tag}`);

let lastStatementIndex = 0;
let lastStatement = '';
try {
for (const [index, statement] of migration.sql.entries()) {
lastStatementIndex = index;
lastStatement = statement;

if (logStatements) {
console.log(` 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) {
// 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;
}
}

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;
});
Loading