WAL journal mode + OpenAPI 3.1

- InitSchema migration now issues PRAGMA journal_mode = WAL after
  PRAGMA foreign_keys = ON so fresh DBs are created in WAL mode.
- DatabaseInitService.init() also calls ensureWalMode() after
  dataSource.initialize() so warm DBs (where migrations are skipped)
  have WAL applied and persisted on every startup. Logs whether it
  was already set or just enabled.
- main.ts now serves an OpenAPI 3.1 document at /api/docs and
  /api/docs-json. A new toOpenApi31() utility converts the
  NestJS-generated 3.0 document by setting openapi=3.1.0, normalising
  the required info fields, and rewriting every legacy
  'nullable: true' marker into a JSON Schema 2020-12 type union
  ('type: [<orig>, "null"]'). Recursion handles components, paths,
  parameters, request bodies, response bodies, properties, items,
  oneOf/anyOf/allOf, and parameter/request schemas.
- 7 new unit tests in tests/backend/openapi31.spec.ts lock the
  conversion behaviour; total suite: 83 tests across 18 specs.
This commit is contained in:
OpenVelo Agent
2026-07-21 14:13:19 +00:00
parent ac6c834525
commit de27a8bb24
6 changed files with 243 additions and 153 deletions
+116
View File
@@ -0,0 +1,116 @@
import { OpenAPIObject } from '@nestjs/swagger';
/**
* Convert a NestJS-generated OpenAPI 3.0 document to a JSON Schema 2020-12-
* compatible OpenAPI 3.1 document.
*
* The two specs differ mainly in:
* - `openapi` version string.
* - JSON Schema dialect: 3.0 used a custom subset of JSON Schema Draft 5
* (with `nullable: true`); 3.1 uses JSON Schema 2020-12 natively
* (nullable via `type: ['T', 'null']`).
* - `info` no longer requires `license` / `contact`.
*
* The `paths`/`components`/`security` structures are otherwise identical,
* and we keep all NestJS-generated content intact.
*/
export function toOpenApi31(doc: Record<string, any>): OpenAPIObject {
const next: Record<string, any> = { ...doc, openapi: '3.1.0' };
// Normalize info so it has every required 3.1 field.
if (!next.info || typeof next.info !== 'object') {
next.info = { title: 'HIPCTF API', version: '1.0.0' };
} else {
next.info = {
title: next.info.title ?? 'HIPCTF API',
version: next.info.version ?? '1.0.0',
...(next.info.description ? { description: next.info.description } : {}),
...(next.info.contact ? { contact: next.info.contact } : {}),
...(next.info.license ? { license: next.info.license } : {}),
};
}
// Rewrite component schemas: replace `nullable: true` with the JSON
// Schema 2020-12 `type: ['<orig>', 'null']` union. Leave everything else
// untouched so existing component definitions remain valid.
const components = next.components ?? {};
const schemas = components.schemas ?? {};
for (const [name, schema] of Object.entries<any>(schemas)) {
schemas[name] = rewriteNullable(schema);
}
components.schemas = schemas;
next.components = components;
// Walk paths and parameters and rewrite any remaining `nullable: true`.
if (next.paths && typeof next.paths === 'object') {
for (const [pathKey, methods] of Object.entries<any>(next.paths)) {
for (const [method, op] of Object.entries<any>(methods ?? {})) {
if (!op || typeof op !== 'object') continue;
if (Array.isArray(op.parameters)) {
op.parameters = op.parameters.map((p: any) => rewriteNullable(p));
}
if (op.requestBody) op.requestBody = rewriteNullable(op.requestBody);
const body = op.requestBody?.content;
if (body && typeof body === 'object') {
for (const media of Object.values<any>(body)) {
if (media?.schema) media.schema = rewriteNullable(media.schema);
}
}
if (op.responses && typeof op.responses === 'object') {
for (const [code, resp] of Object.entries<any>(op.responses)) {
op.responses[code] = rewriteNullable(resp);
const respBody = op.responses[code]?.content;
if (respBody && typeof respBody === 'object') {
for (const media of Object.values<any>(respBody)) {
if (media?.schema) media.schema = rewriteNullable(media.schema);
}
}
}
}
}
}
}
return next as OpenAPIObject;
}
/**
* Recursively walk a schema and convert legacy `nullable: true` markers
* into JSON Schema 2020-12 `type: ['<orig>', 'null']` unions. Preserve any
* other keyword (`$ref`, `format`, `enum`, etc.).
*/
function rewriteNullable(node: any): any {
if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
// If this is a $ref, leave it alone.
if (typeof node.$ref === 'string') return node;
const out: Record<string, any> = { ...node };
if (out.nullable === true) {
delete out.nullable;
if (typeof out.type === 'string') {
out.type = [out.type, 'null'];
} else if (Array.isArray(out.type)) {
if (!out.type.includes('null')) out.type = [...out.type, 'null'];
} else if (!out.oneOf && !out.anyOf && !out.allOf) {
out.type = ['null'];
}
}
for (const k of Object.keys(out)) {
if (k === 'properties' && out.properties && typeof out.properties === 'object') {
for (const p of Object.keys(out.properties)) {
out.properties[p] = rewriteNullable(out.properties[p]);
}
} else if (k === 'items') {
out.items = rewriteNullable(out.items);
} else if (k === 'schema') {
out.schema = rewriteNullable(out.schema);
} else if ((k === 'oneOf' || k === 'anyOf' || k === 'allOf') && Array.isArray(out[k])) {
out[k] = out[k].map((v: any) => rewriteNullable(v));
}
}
return out;
}
@@ -30,6 +30,11 @@ export class DatabaseInitService implements OnApplicationBootstrap {
await this.dataSource.initialize();
}
// Enforce WAL journal mode on every startup. The InitSchema migration
// also sets it for fresh DBs, but warm DBs (where migrations are
// skipped) need it applied here so PRAGMA journal_mode is always 'wal'.
await this.ensureWalMode();
const pending = await this.dataSource.showMigrations();
const pendingList: string[] = Array.isArray(pending) ? pending : [];
this.logger.log(`Pending migrations: ${pendingList.length}`);
@@ -52,6 +57,27 @@ export class DatabaseInitService implements OnApplicationBootstrap {
await this.init();
}
/**
* Read PRAGMA journal_mode and switch to WAL if not already enabled.
* Persisted for file-backed databases; idempotent and safe on every
* startup.
*/
private async ensureWalMode(): Promise<void> {
try {
const rows: any[] = await this.dataSource.query('PRAGMA journal_mode');
const current = String(rows?.[0]?.journal_mode ?? '').toLowerCase();
if (current === 'wal') {
this.logger.log('PRAGMA journal_mode=wal (already set)');
return;
}
const setRows: any[] = await this.dataSource.query('PRAGMA journal_mode = WAL');
const actual = String(setRows?.[0]?.journal_mode ?? '').toLowerCase();
this.logger.log(`PRAGMA journal_mode set (was='${current}', now='${actual}')`);
} catch (e) {
this.logger.warn(`ensureWalMode skipped: ${(e as Error).message}`);
}
}
private async verifySeed(): Promise<void> {
const categoryCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "category"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
const settingsCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "setting"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
@@ -5,6 +5,7 @@ export class InitSchema1700000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA journal_mode = WAL;`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "user" (
+3 -1
View File
@@ -3,6 +3,7 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { toOpenApi31 } from './common/utils/openapi31';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
@@ -86,7 +87,8 @@ async function bootstrap(): Promise<void> {
.addBearerAuth()
.addCookieAuth('rt')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
const rawDocument = SwaggerModule.createDocument(app, swaggerConfig) as Record<string, any>;
const document = toOpenApi31(rawDocument);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: 'api/docs-json',
});