dorfteich/apps/api/src/main.ts
Claude Fable 5 a7760335cb
Some checks failed
CD / Promote to Int (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Failing after 1m11s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 2m48s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Has been cancelled
Remove an unused eslint-disable directive in main.ts
no-console is not enforced for apps/api, so the directive on the
boot-time restore-wait log line was flagged as unused and failed the
lint gate (--report-unused-disable-directives). Keep the console.log
and its rationale as a plain comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 20:45:29 +02:00

89 lines
3.6 KiB
TypeScript

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<void> {
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<void> {
// 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<NestExpressApplication>(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();