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
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
89 lines
3.6 KiB
TypeScript
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();
|