import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; import cookieParser from 'cookie-parser'; import { RESTORE_STALE_MAX_AGE_MINUTES, RESTORE_STATUS_FILE, type RestoreStatus, } from '@dorfteich/shared'; import { NestFactory } from '@nestjs/core'; import type { NestExpressApplication } from '@nestjs/platform-express'; import { Logger } from 'nestjs-pino'; import { AppModule } from './app.module'; import { AppConfig, loadApiEnv } from './config/app-config.service'; /** * Apply pending migrations before the application accepts traffic, so * self-hosters update by simply pulling a new image (ADR 0002). Prisma * serializes concurrent deploys with a database advisory lock. */ function runMigrations(): void { const prismaCli = require.resolve('prisma/build/index.js'); execFileSync(process.execPath, [prismaCli, 'migrate', 'deploy'], { stdio: 'inherit' }); } /** * An api starting while the backup sidecar restores the database (issue * #103) must not touch it: `migrate deploy` racing `pg_restore --clean` * would corrupt the restore. This happens in practice — the restore * terminates every connection, which can crash a worker pass and have * Docker restart the container mid-restore. Wait for the sidecar's status * file to leave `running` (bounded by the same staleness rule as the * maintenance gate) before doing anything with the database. */ async function waitWhileRestoreRuns(backupsDir: string): Promise { const path = join(backupsDir, RESTORE_STATUS_FILE); for (;;) { let status: RestoreStatus | null = null; try { status = existsSync(path) ? (JSON.parse(readFileSync(path, 'utf8')) as RestoreStatus) : null; } catch { status = null; } if (!status || status.state !== 'running') return; const ageMinutes = (Date.now() - new Date(status.startedAt).getTime()) / 60_000; if (!Number.isFinite(ageMinutes) || ageMinutes > RESTORE_STALE_MAX_AGE_MINUTES) return; // The pino logger does not exist yet at this point in boot, so this one // status line goes to the console directly (no-console is not enforced here). console.log(`restore of ${status.backupId} is running — waiting before touching the database`); await sleep(2000); } } async function bootstrap(): Promise { // Validate the environment before doing anything with it; this throws a // readable list of problems and prevents a half-started process. const env = loadApiEnv(); await waitWhileRestoreRuns(env.BACKUPS_DIR); if (env.MIGRATE_ON_START) { runMigrations(); } // bufferLogs holds early log lines until the pino logger is attached, // so even bootstrap errors come out as structured JSON. const app = await NestFactory.create(AppModule, { bufferLogs: true }); app.useLogger(app.get(Logger)); // One reverse-proxy hop (Caddy) in front of us: req.ip must reflect the // real client for rate limiting and audit logs. app.set('trust proxy', 1); app.use(cookieParser()); // Base64-encoded Yjs page state (max 5 MiB, operations.md) inflates by // ~4/3; 8 MiB leaves headroom for the JSON envelope around it. app.useBodyParser('json', { limit: '8mb' }); // The public API (issue #104) lives at /api/public/v1 — its controllers // declare the full path and are excluded from the SPA prefix. app.setGlobalPrefix('api/v1', { exclude: ['api/public/v1', 'api/public/v1/{*path}', 'api/mcp'], }); app.enableShutdownHooks(); const config = app.get(AppConfig); await app.listen(config.env.PORT); } void bootstrap();