Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ DATABASE_POOL_TIMEOUT_MS=5000
DATABASE_QUERY_TIMEOUT_MS=5000
DATABASE_STATEMENT_TIMEOUT_MS=10000
DATABASE_WORKER_QUERY_TIMEOUT_MS=60000
# Boot-time migration status check: strict | warn | off.
# Unset = strict in production (abort startup on pending/failed migrations),
# warn everywhere else. DATABASE_MIGRATIONS_DIR overrides <cwd>/prisma/migrations.
# DATABASE_MIGRATION_CHECK=warn
# DATABASE_MIGRATIONS_DIR=/app/prisma/migrations
# Startup connection retry policy (exponential backoff, attempts include first try)
DATABASE_CONNECT_RETRY_ATTEMPTS=5
DATABASE_CONNECT_RETRY_DELAY_MS=1000
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ are rejected:
| `DATABASE_CONNECT_RETRY_DELAY_MS` | `1000` | Delay between connection retry attempts. |
| `DATABASE_MIGRATION_CHECK_ENABLED` | `true` | Runs a migration status check during bootstrap before the app accepts traffic. |
| `DATABASE_MIGRATION_CHECK_MODE` | `halt` | `halt` exits the process when migrations are pending/failed; `warn` logs and continues. |
| `DATABASE_MIGRATION_CHECK` | `strict` in production, `warn` otherwise | Boot-time migration gate used by `PrismaService.verifyMigrations()`: `strict` aborts startup on pending/failed migrations or an unreadable migration history, `warn` only logs, `off` skips the check. |
| `DATABASE_MIGRATIONS_DIR` | `prisma/migrations` | Overrides where the migrations folder is read from for the `DATABASE_MIGRATION_CHECK` gate. |

### Redis

Expand Down
14 changes: 14 additions & 0 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ The API retries PostgreSQL connections during startup using exponential backoff.
- Run `npm run db:verify` locally to execute `scripts/verify-migrations.sh` prior to opening a pull request.
- The CI pipeline automatically runs `scripts/verify-migrations.sh` to validate schema syntax, migration structure, and working tree cleanliness.

## Startup Migration Check
Before the HTTP server starts listening, `main.ts` calls `PrismaService.verifyMigrations()`, which compares the folders in `prisma/migrations` with the rows in `_prisma_migrations` (rolled-back rows are ignored). The behaviour is controlled by `DATABASE_MIGRATION_CHECK`:

| Mode | Default for | Pending or failed migrations | Migration history unreadable (e.g. DB down) |
|------|-------------|------------------------------|---------------------------------------------|
| `strict` | `NODE_ENV=production` | Logs the offending migrations with remediation steps and aborts startup (exit code 1) | Aborts startup |
| `warn` | every other environment | Logs the same instructions and keeps booting | Logs a warning and keeps booting |
| `off` | never | Check skipped | Check skipped |

To recover from an aborted start:
- Pending migrations: run `npm run prisma:deploy` against the target `DATABASE_URL`, then restart the service.
- Failed migrations: inspect the `logs` column in `_prisma_migrations`, repair the database, run `npx prisma migrate resolve --rolled-back <name>` (or `--applied <name>`), then redeploy.

If the runtime image keeps migrations somewhere other than `<cwd>/prisma/migrations`, point `DATABASE_MIGRATIONS_DIR` at that folder. When no migrations are found on disk, the check logs a warning instead of silently passing.
## Migration CLI and Rollback Protection

`npm run db:migrate -- <command>` wraps the Prisma migration commands behind a production safety guard (`src/database/migration-guard.ts`).
Expand Down
9 changes: 9 additions & 0 deletions src/config/database.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { registerAs } from '@nestjs/config';
import { databaseEnvSchema, validateEnv } from './env.validation';
import { MigrationCheckMode, resolveMigrationCheckMode } from '../database/migration-checker';

/**
* Database connection configuration.
Expand All @@ -15,6 +16,10 @@ import { databaseEnvSchema, validateEnv } from './env.validation';
* workers. It carries no server-side `statement_timeout` and a much longer
* client-side guard so long-running worker transactions are never aborted by
* the API-oriented timeouts.
*
* `migrationCheck` controls the boot-time migration status gate run by
* `main.ts` before the HTTP server listens (see `verifyMigrationsOnStartup`);
* `migrationsDir` overrides where the migrations folder is read from.
*/
export type DatabaseConfig = {
url: string;
Expand All @@ -24,6 +29,8 @@ export type DatabaseConfig = {
queryTimeoutMs: number;
statementTimeoutMs: number;
workerQueryTimeoutMs: number;
migrationCheck: MigrationCheckMode;
migrationsDir?: string;
/** Wall-time threshold above which a query is logged as slow (0 = disabled). */
slowQueryThresholdMs: number;
connectionRetryAttempts: number;
Expand All @@ -42,6 +49,8 @@ export const databaseConfig = registerAs('database', (): DatabaseConfig => {
queryTimeoutMs: env.DATABASE_QUERY_TIMEOUT_MS,
statementTimeoutMs: env.DATABASE_STATEMENT_TIMEOUT_MS,
workerQueryTimeoutMs: env.DATABASE_WORKER_QUERY_TIMEOUT_MS,
migrationCheck: resolveMigrationCheckMode(env.DATABASE_MIGRATION_CHECK, process.env.NODE_ENV),
migrationsDir: env.DATABASE_MIGRATIONS_DIR,
slowQueryThresholdMs: env.DATABASE_SLOW_QUERY_THRESHOLD_MS,
connectionRetryAttempts: env.DATABASE_CONNECT_RETRY_ATTEMPTS,
connectionRetryDelayMs: env.DATABASE_CONNECT_RETRY_DELAY_MS,
Expand Down
7 changes: 7 additions & 0 deletions src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export const databaseEnvSchema = z.object({
// worker transactions (rollups, outbox drains) must not be killed by the API
// guard; 0 disables the worker guard entirely.
DATABASE_WORKER_QUERY_TIMEOUT_MS: z.coerce.number().int().nonnegative().default(60000),
// Boot-time migration status check: `strict` aborts startup on pending or
// failed migrations, `warn` only logs, `off` skips it. When unset, production
// is strict and every other environment warns.
DATABASE_MIGRATION_CHECK: z.enum(['strict', 'warn', 'off']).optional(),
// Location of the Prisma migrations folder the check compares against.
// Defaults to `<cwd>/prisma/migrations`.
DATABASE_MIGRATIONS_DIR: z.string().min(1).optional(),
// Slow query logging threshold (ms). Queries exceeding this emit a warn log.
// 0 disables slow query logging.
DATABASE_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().nonnegative().default(1000),
Expand Down
232 changes: 232 additions & 0 deletions src/database/migration-checker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import {
getAppliedMigrations,
checkMigrationStatus,
getDefaultMigrationsDir,
formatMigrationInstructions,
MigrationStatusUnavailableError,
PendingMigrationsError,
resolveMigrationCheckMode,
verifyMigrationsOnStartup,
} from './migration-checker';

// Mock fs module
Expand Down Expand Up @@ -140,6 +145,42 @@ describe('MigrationChecker', () => {
expect(result.size).toBe(0);
});

it('should treat a Postgres 42P01 (undefined_table) error as a fresh database', async () => {
const mockPrisma = {
$queryRawUnsafe: vi.fn().mockRejectedValue(
new Error('Raw query failed. Code: `42P01`. Message: `relation does not exist`'),
),
};

const result = await getAppliedMigrations(mockPrisma as unknown as import('@prisma/client').PrismaClient);

expect(result.size).toBe(0);
});

it('should rethrow errors other than a missing migrations table', async () => {
const mockPrisma = {
$queryRawUnsafe: vi.fn().mockRejectedValue(
new Error("Can't reach database server at `localhost:5432`"),
),
};

await expect(
getAppliedMigrations(mockPrisma as unknown as import('@prisma/client').PrismaClient),
).rejects.toThrow("Can't reach database server");
});

it('should exclude rolled-back migrations from the query', async () => {
const mockPrisma = {
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
};

await getAppliedMigrations(mockPrisma as unknown as import('@prisma/client').PrismaClient);

expect(mockPrisma.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('WHERE rolled_back_at IS NULL'),
);
});

it('should return empty map when no migrations have been applied', async () => {
const mockPrisma = {
$queryRawUnsafe: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -308,4 +349,195 @@ describe('MigrationChecker', () => {
expect(dir).toMatch(/prisma[\\/]migrations$/);
});
});

describe('resolveMigrationCheckMode', () => {
it('should default to strict in production', () => {
expect(resolveMigrationCheckMode(undefined, 'production')).toBe('strict');
});

it('should default to warn outside production', () => {
expect(resolveMigrationCheckMode(undefined, 'development')).toBe('warn');
expect(resolveMigrationCheckMode(undefined, 'test')).toBe('warn');
expect(resolveMigrationCheckMode(undefined, undefined)).toBe('warn');
});

it('should let an explicit mode override the environment default', () => {
expect(resolveMigrationCheckMode('warn', 'production')).toBe('warn');
expect(resolveMigrationCheckMode('strict', 'development')).toBe('strict');
expect(resolveMigrationCheckMode('off', 'production')).toBe('off');
});
});

describe('formatMigrationInstructions', () => {
const pending = { name: '20260901_add_x', applied: false, finished: false, error: null };
const failed = { name: '20260830_add_y', applied: true, finished: false, error: 'boom' };

it('should list pending migrations with the deploy command', () => {
const text = formatMigrationInstructions({
upToDate: false,
migrations: [pending],
pending: [pending],
failed: [],
message: '',
});

expect(text).toContain('Pending migrations (1): 20260901_add_x');
expect(text).toContain('npm run prisma:deploy');
expect(text).not.toContain('migrate resolve');
expect(text).toContain('DATABASE_MIGRATION_CHECK=warn');
});

it('should list failed migrations with the resolve command', () => {
const text = formatMigrationInstructions({
upToDate: false,
migrations: [failed],
pending: [],
failed: [failed],
message: '',
});

expect(text).toContain('Failed migrations (1): 20260830_add_y');
expect(text).toContain('prisma migrate resolve --rolled-back <name>');
expect(text).not.toContain('prisma:deploy');
});
});

describe('verifyMigrationsOnStartup', () => {
const logger = { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
type Client = import('@prisma/client').PrismaClient;

function givenMigrationsOnDisk(names: string[]): void {
mockFs.existsSync.mockReturnValue(true);
mockFs.readdirSync.mockReturnValue(
names.map(
(name) => ({ name, isDirectory: () => true, isFile: () => false }) as fs.Dirent,
),
);
}

function prismaWithApplied(names: string[]): Client {
return {
$queryRawUnsafe: vi.fn().mockResolvedValue(
names.map((migration_name) => ({
migration_name,
finished_at: new Date('2026-08-30T10:00:00Z'),
logs: null,
})),
),
} as unknown as Client;
}

function unreachablePrisma(): Client {
return {
$queryRawUnsafe: vi.fn().mockRejectedValue(new Error('Cannot reach database server')),
} as unknown as Client;
}

it('should proceed when every migration is applied', async () => {
givenMigrationsOnDisk(['20260830_01_init']);

const result = await verifyMigrationsOnStartup(prismaWithApplied(['20260830_01_init']), {
mode: 'strict',
migrationsDir: '/fake/migrations',
logger,
});

expect(result?.upToDate).toBe(true);
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining('up to date'));
expect(logger.error).not.toHaveBeenCalled();
});

it('should abort with PendingMigrationsError in strict mode when migrations are pending', async () => {
givenMigrationsOnDisk(['20260830_01_init', '20260830_02_add_users']);

const run = verifyMigrationsOnStartup(prismaWithApplied(['20260830_01_init']), {
mode: 'strict',
migrationsDir: '/fake/migrations',
logger,
});

await expect(run).rejects.toBeInstanceOf(PendingMigrationsError);
await expect(run).rejects.toThrow('20260830_02_add_users');
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Aborting startup'));
});

it('should expose the check result on PendingMigrationsError', async () => {
givenMigrationsOnDisk(['20260830_01_init']);

const error = await verifyMigrationsOnStartup(prismaWithApplied([]), {
mode: 'strict',
migrationsDir: '/fake/migrations',
logger,
}).catch((e: unknown) => e);

expect(error).toBeInstanceOf(PendingMigrationsError);
expect((error as PendingMigrationsError).result.pending.map((m) => m.name)).toEqual([
'20260830_01_init',
]);
});

it('should log and continue in warn mode when migrations are pending', async () => {
givenMigrationsOnDisk(['20260830_01_init', '20260830_02_add_users']);

const result = await verifyMigrationsOnStartup(prismaWithApplied(['20260830_01_init']), {
mode: 'warn',
migrationsDir: '/fake/migrations',
logger,
});

expect(result?.upToDate).toBe(false);
expect(result?.pending.map((m) => m.name)).toEqual(['20260830_02_add_users']);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Continuing startup'));
});

it('should abort with MigrationStatusUnavailableError in strict mode when the DB is unreachable', async () => {
givenMigrationsOnDisk(['20260830_01_init']);

await expect(
verifyMigrationsOnStartup(unreachablePrisma(), {
mode: 'strict',
migrationsDir: '/fake/migrations',
logger,
}),
).rejects.toBeInstanceOf(MigrationStatusUnavailableError);
});

it('should warn and continue in warn mode when the DB is unreachable', async () => {
givenMigrationsOnDisk(['20260830_01_init']);

const result = await verifyMigrationsOnStartup(unreachablePrisma(), {
mode: 'warn',
migrationsDir: '/fake/migrations',
logger,
});

expect(result).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Unable to read the migration status'),
);
});

it('should skip the check entirely in off mode', async () => {
const prisma = prismaWithApplied([]);

const result = await verifyMigrationsOnStartup(prisma, { mode: 'off', logger });

expect(result).toBeNull();
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('disabled'));
});

it('should warn rather than abort when no migrations ship with the build', async () => {
mockFs.existsSync.mockReturnValue(false);

const result = await verifyMigrationsOnStartup(prismaWithApplied([]), {
mode: 'strict',
migrationsDir: '/missing/migrations',
logger,
});

expect(result?.upToDate).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('DATABASE_MIGRATIONS_DIR'));
});
});
});
Loading
Loading