Files
sentryagent-idp/scripts/migrate.ts
SentryAgent.ai Developer d3530285b9 feat: Phase 1 MVP — complete AgentIdP implementation
Implements all P0 features per OpenSpec change phase-1-mvp-implementation:
- Agent Registry Service (CRUD) — full lifecycle management
- OAuth 2.0 Token Service (Client Credentials flow)
- Credential Management (generate, rotate, revoke)
- Immutable Audit Log Service

Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+
Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types
Quality: 18 unit test suites, 244 tests passing, 97%+ coverage
OpenAPI: 4 complete specs (14 endpoints total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 09:14:41 +00:00

121 lines
3.3 KiB
TypeScript

/**
* Database migration runner for SentryAgent.ai AgentIdP.
* Reads all .sql files from src/db/migrations/ in alphabetical order,
* tracks applied migrations in a schema_migrations table, and executes
* only unapplied migrations.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
dotenv.config();
const MIGRATIONS_DIR = path.join(__dirname, '../src/db/migrations');
interface MigrationRow {
name: string;
applied_at: Date;
}
/**
* Ensures the schema_migrations tracking table exists.
*
* @param pool - The PostgreSQL connection pool.
*/
async function ensureMigrationsTable(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
}
/**
* Returns the list of already-applied migration names.
*
* @param pool - The PostgreSQL connection pool.
* @returns Array of applied migration names.
*/
async function getAppliedMigrations(pool: Pool): Promise<string[]> {
const result = await pool.query<MigrationRow>('SELECT name FROM schema_migrations ORDER BY name');
return result.rows.map((row) => row.name);
}
/**
* Applies a single migration file within a transaction.
*
* @param pool - The PostgreSQL connection pool.
* @param name - The migration file name (without path).
* @param sql - The SQL to execute.
*/
async function applyMigration(pool: Pool, name: string, sql: string): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [name]);
await client.query('COMMIT');
// eslint-disable-next-line no-console
console.log(` ✓ Applied: ${name}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
/**
* Main migration runner.
* Reads all .sql files in alphabetical order and applies unapplied ones.
*/
async function migrate(): Promise<void> {
const connectionString = process.env['DATABASE_URL'];
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required');
}
const pool = new Pool({ connectionString });
try {
// eslint-disable-next-line no-console
console.log('Running database migrations...');
await ensureMigrationsTable(pool);
const applied = await getAppliedMigrations(pool);
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();
let count = 0;
for (const file of files) {
if (applied.includes(file)) {
// eslint-disable-next-line no-console
console.log(` - Skipped (already applied): ${file}`);
continue;
}
const filePath = path.join(MIGRATIONS_DIR, file);
const sql = fs.readFileSync(filePath, 'utf-8');
await applyMigration(pool, file, sql);
count++;
}
// eslint-disable-next-line no-console
console.log(`\nMigrations complete. ${count} migration(s) applied.`);
} finally {
await pool.end();
}
}
migrate().catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error('Migration failed:', err);
process.exit(1);
});