From 2490e0b6f2a6b06a8607654086a3a493781773e2 Mon Sep 17 00:00:00 2001 From: ifLov Date: Mon, 31 Aug 2026 15:36:54 +0900 Subject: [PATCH 1/2] refactor: move the BullMQ root into the queue module BullModule.forRootAsync was assembled in AppModule while the queue, adapter and processor were assembled in QueueModule, so one broker's composition root was split across two files and swapping BullMQ for another broker meant editing both. Move the root connection into QueueModule so AppModule only selects a module. This also narrows when the Bull root is registered: it used to be created whenever BULLMQ_ENABLED was set, and now follows the existing `bullmqEnabled && redisEnabled` gate, so BULLMQ_ENABLED=true with REDIS_ENABLED=false no longer opens a Redis connection. Verified: e2e passes with BULLMQ_ENABLED=true and REDIS_ENABLED=false (2 files / 20 tests). No queue generator profile is introduced. BullMQ is the only supported broker today, and broker replacement changes delivery, retry and DLQ policy rather than just a connection, so the profile axis waits for a second broker and its delivery contract. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qkwp4T95EwEXeoDozJ6Y4m --- CLAUDE.md | 3 +++ src/app.module.ts | 13 ------------- src/infra/queue/queue.module.ts | 9 +++++++++ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5786c16..9341bca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,9 @@ adapter/in → application command/query → application port → adapter/out - application/domain에서 TypeORM, Prisma, Mongoose, Redis, BullMQ 타입을 import하지 않는다. - RDB와 MongoDB를 함께 쓸 수 있지만 한 aggregate의 authoritative store는 하나다. - 메시지 발행은 `MessageQueuePort`를 사용하고 BullMQ 타입을 호출부로 노출하지 않는다. +- BullMQ는 기본 messaging composition root다. +- broker 교체는 연결만이 아니라 delivery, retry, DLQ 정책을 함께 바꾸는 작업이다. +- 새 broker는 AppModule이 아니라 messaging infrastructure module에서 교체한다. ## 생성 시 선택 diff --git a/src/app.module.ts b/src/app.module.ts index 8c848a4..ad4cbe4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,3 @@ -import { BullModule } from '@nestjs/bullmq'; import { CacheModule } from '@nestjs/cache-manager'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -13,7 +12,6 @@ import { validationSchema } from './config/env.validation.js'; import { featureFlags } from './config/feature-flags.js'; import { envFilePaths } from './config/load-env.js'; import { buildPinoConfig } from './config/pino.config.js'; -import { buildBullConnectionOptions } from './config/redis.config.js'; import { HealthModule } from './health/health.module.js'; import { MetricsModule } from './infra/metrics/metrics.module.js'; import { MongoDatabaseModule } from './infra/mongodb/mongodb.module.js'; @@ -72,17 +70,6 @@ const { }), RdbDatabaseModule, ...(mongodbEnabled ? [MongoDatabaseModule] : []), - ...(bullmqEnabled - ? [ - BullModule.forRootAsync({ - inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - connection: buildBullConnectionOptions(configService), - prefix: configService.get('BULLMQ_PREFIX') ?? 'app', - }), - }), - ] - : []), HealthModule, ...(metricsEnabled ? [MetricsModule] : []), RedisModule, diff --git a/src/infra/queue/queue.module.ts b/src/infra/queue/queue.module.ts index e275e2b..5232022 100644 --- a/src/infra/queue/queue.module.ts +++ b/src/infra/queue/queue.module.ts @@ -1,7 +1,9 @@ import { BullModule } from '@nestjs/bullmq'; import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { MESSAGE_QUEUE } from '../../common/messaging/message-queue.port.js'; +import { buildBullConnectionOptions } from '../../config/redis.config.js'; import { BACKGROUND_JOBS_QUEUE } from './background-jobs.constants.js'; import { BackgroundJobsProcessor } from './background-jobs.processor.js'; import { BackgroundJobsService } from './background-jobs.service.js'; @@ -10,6 +12,13 @@ import { BullmqMetricsService } from './bullmq-metrics.service.js'; @Module({ imports: [ + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + connection: buildBullConnectionOptions(configService), + prefix: configService.get('BULLMQ_PREFIX') ?? 'app', + }), + }), BullModule.registerQueue({ name: BACKGROUND_JOBS_QUEUE, }), From 53c072e2b37bec0acb1207e16f516d813bf7b4fe Mon Sep 17 00:00:00 2001 From: ifLov Date: Mon, 31 Aug 2026 15:37:22 +0900 Subject: [PATCH 2/2] fix(scaffold): generate and validate Compose structurally The generator located the RDB service block with a regex whose end was "the redis service comes next", an undocumented ordering invariant of docker-compose.yml. Any service inserted between the database and redis was swallowed by the lazy match and deleted, with no error. Reproduced by calling materializeTypeOrmCompose directly: an added pgadmin service did not survive materialization to mysql. check:scaffold could not catch it. Its Compose check only asserted that the selected database image was present; it never asserted that an unselected one was absent, while every other axis in the validator (drivers, ORM dependencies, module markers) is bidirectional. A compose carrying both postgres and mysql reported "Scaffold selection is consistent". Leftover profile files were unchecked the same way. Rewrite both on top of a YAML document: - materializeTypeOrmCompose edits service nodes instead of substituting text, and requires exactly one primary RDB service. Other services, top-level sections and comments are preserved. - scaffoldConsistencyIssues reads both Compose files structurally and checks the selected image, the app service's depends_on and DB_* env, ports, tmpfs, leftover RDB services and leftover ORM profile files. Also isolate the e2e database. README, CLAUDE.md and AGENTS.md all documented `DB_PORT=15432 pnpm test:e2e`, but 15432 is the CI service container port and nothing in docker-compose.yml published it, so the documented command could not connect on a clean clone. Dropping the override sent an e2e suite that runs `synchronize: true` and truncates in beforeEach at the development database on 5432. docker-compose.test.yml now runs the selected RDB in its own Compose project on tmpfs and publishes 15432, and the e2e default port points at it. The e2e datasource derives its driver from DB_TYPE instead of hardcoding postgres, so the engine has one materialized source rather than three. Running the e2e against a real MySQL for the first time surfaced an engine-specific defect the PostgreSQL-only runs had hidden: QueryFailedError: BLOB/TEXT column 'name' used in key specification without a key length The shared fixture's `text` unique column only worked on PostgreSQL. It is now varchar(255), and `schema` is passed for PostgreSQL only. Verified: check:scaffold, check:observability, lint, typecheck and build pass; 35 files / 317 tests; both Compose files produce no config warnings; the documented e2e path passes on PostgreSQL (2 files / 20 tests) and on a generated TypeORM/MySQL project (2 files / 20 tests); MikroORM/PostgreSQL and TypeORM/MySQL materialize and typecheck. The original C1 and C2 reproductions now preserve the added service and exit 1 respectively. Not verified: MikroORM and Prisma on MySQL/MariaDB, MariaDB e2e, and Docker e2e for the alternative ORMs. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qkwp4T95EwEXeoDozJ6Y4m --- AGENTS.md | 5 +- CLAUDE.md | 4 +- README.md | 9 +- docker-compose.test.yml | 19 +++ package.json | 3 +- pnpm-lock.yaml | 3 + scripts/check-scaffold.ts | 5 + scripts/select-scaffold.ts | 5 +- src/config/scaffold.config.spec.ts | 139 ++++++++++++++- src/config/scaffold.config.ts | 177 ++++++++++++++++++- src/config/typeorm-rdb-generator.spec.ts | 63 ++++++- src/config/typeorm-rdb-generator.ts | 207 +++++++++++++++-------- test/app.e2e-spec.ts | 4 +- test/load-test-env.ts | 6 +- test/persistence.e2e-spec.ts | 26 ++- 15 files changed, 574 insertions(+), 101 deletions(-) create mode 100644 docker-compose.test.yml diff --git a/AGENTS.md b/AGENTS.md index 5af63bf..571e9b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,9 +89,12 @@ pnpm check:observability pnpm lint pnpm typecheck pnpm test -DB_PORT=15432 pnpm test:e2e +docker compose -f docker-compose.test.yml up -d --wait +pnpm test:e2e +docker compose -f docker-compose.test.yml down pnpm build docker compose config --quiet +docker compose -f docker-compose.test.yml config --quiet ``` ORM/RDB 프로필을 바꾸면 선택한 조합의 실제 Docker E2E도 실행한다. diff --git a/CLAUDE.md b/CLAUDE.md index 9341bca..a9717dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,9 @@ pnpm check:observability pnpm lint pnpm typecheck pnpm test -DB_PORT=15432 pnpm test:e2e +docker compose -f docker-compose.test.yml up -d --wait +pnpm test:e2e +docker compose -f docker-compose.test.yml down pnpm build ``` diff --git a/README.md b/README.md index 3d88cd4..b9fe0bc 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,14 @@ pnpm check:observability pnpm lint pnpm typecheck pnpm test -DB_PORT=15432 pnpm test:e2e +docker compose -f docker-compose.test.yml up -d --wait +pnpm test:e2e +docker compose -f docker-compose.test.yml down pnpm build docker compose config --quiet +docker compose -f docker-compose.test.yml config --quiet ``` -실제 E2E는 sql.js 같은 인메모리 대체물이 아니라 Docker RDB를 사용합니다. +실제 E2E는 sql.js 같은 인메모리 대체물이 아니라 선택된 Docker RDB를 사용합니다. 테스트 전용 +Compose project와 tmpfs가 개발 데이터 및 로컬 개발 Compose lifecycle을 격리하며 기본 호스트 포트는 +15432입니다. diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..100ed93 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,19 @@ +name: barebones-test +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${DB_DATABASE:-app} + POSTGRES_USER: ${DB_USERNAME:-app} + POSTGRES_PASSWORD: ${DB_PASSWORD:-app} + ports: + - '${DB_TEST_HOST_PORT:-15432}:5432' + healthcheck: + test: + - CMD-SHELL + - pg_isready -U ${DB_USERNAME:-app} -d ${DB_DATABASE:-app} + interval: 10s + timeout: 5s + retries: 10 + tmpfs: + - /var/lib/postgresql/data diff --git a/package.json b/package.json index 238a64c..f558982 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "tsx": "^4.23.12", "typescript": "^6.0.0", "typescript-eslint": "^8.68.0", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "yaml": "^2.8.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8724603..c09ceac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -194,6 +194,9 @@ importers: vitest: specifier: ^4.1.11 version: 4.1.11(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.11)(vite@8.2.2(@types/node@24.12.0)(esbuild@0.28.2)(jiti@2.6.1)(terser@5.51.0)(tsx@4.23.12)(yaml@2.8.2)) + yaml: + specifier: ^2.8.2 + version: 2.8.2 packages: '@angular-devkit/core@22.1.5': diff --git a/scripts/check-scaffold.ts b/scripts/check-scaffold.ts index b86c6b8..0d8686f 100644 --- a/scripts/check-scaffold.ts +++ b/scripts/check-scaffold.ts @@ -62,7 +62,12 @@ function main(): void { devDependencies: dependencyNames(packageManifest.devDependencies), envExampleSource: readFileSync('.env.example', 'utf8'), files: new Set(candidateFiles.filter((path) => existsSync(path))), + loadTestEnvSource: readFileSync('test/load-test-env.ts', 'utf8'), + persistenceE2eSource: existsSync('test/persistence.e2e-spec.ts') + ? readFileSync('test/persistence.e2e-spec.ts', 'utf8') + : undefined, rdbModuleSource: readFileSync('src/infra/rdb/rdb-database.module.ts', 'utf8'), + testComposeSource: readFileSync('docker-compose.test.yml', 'utf8'), }; const issues = scaffoldConsistencyIssues(config, state); issues.push( diff --git a/scripts/select-scaffold.ts b/scripts/select-scaffold.ts index a8b8ec3..9ae7b36 100644 --- a/scripts/select-scaffold.ts +++ b/scripts/select-scaffold.ts @@ -11,6 +11,7 @@ import { materializeTypeOrmCompose, materializeTypeOrmEnv, renderActiveScaffold, + renderTestCompose, selectedTypeOrmDriver, } from '../src/config/typeorm-rdb-generator.js'; @@ -77,10 +78,9 @@ function withDatabaseUrlCompose(source: string, database: RdbChoice): string { } function materializeTestDatabaseEnv(source: string, database: RdbChoice): string { - const port = database === 'postgres' ? 5432 : 3306; return source .replace(/^process\.env\.DB_TYPE = '.*';$/m, `process.env.DB_TYPE = '${database}';`) - .replace(/^process\.env\.DB_PORT \?\?= '.*';$/m, `process.env.DB_PORT ??= '${port}';`) + .replace(/^process\.env\.DB_PORT \?\?= '.*';$/m, "process.env.DB_PORT ??= '15432';") .replace( /^\/\/ E2E는 인메모리 DB 대신.*$/m, `// E2E는 인메모리 DB 대신 docker-compose의 실제 ${database}를 사용한다.`, @@ -298,6 +298,7 @@ function main(): void { files['.env.example'] = env; files['barebones.config.json'] = `${JSON.stringify(selection, null, 2)}\n`; files['docker-compose.yml'] = compose; + files['docker-compose.test.yml'] = renderTestCompose(selection.rdb.database); files['package.json'] = `${JSON.stringify(packageManifest, null, 2)}\n`; files['test/load-test-env.ts'] = materializeTestDatabaseEnv( readFileSync('test/load-test-env.ts', 'utf8'), diff --git a/src/config/scaffold.config.spec.ts b/src/config/scaffold.config.spec.ts index fc1b1fc..4546f37 100644 --- a/src/config/scaffold.config.spec.ts +++ b/src/config/scaffold.config.spec.ts @@ -44,7 +44,19 @@ function typeOrmPostgresState(): ScaffoldState { return { activeScaffoldSource: "database: 'postgres', orm: 'typeorm'", appModuleSource: 'imports: [RdbDatabaseModule]', - composeSource: 'image: postgres:17-alpine', + composeSource: `services: + app: + depends_on: + postgres: + condition: service_healthy + environment: + DB_TYPE: postgres + DB_HOST: \${DOCKER_DB_HOST:-postgres} + DB_PORT: \${DB_PORT:-5432} + postgres: + image: postgres:17-alpine +volumes: {} +`, dependencies: new Set(['@nestjs/typeorm', 'pg', 'typeorm']), devDependencies: new Set(), envExampleSource: 'DB_TYPE=postgres', @@ -53,7 +65,19 @@ function typeOrmPostgresState(): ScaffoldState { 'src/config/typeorm-data-source.ts', 'src/infra/rdb/rdb-database.module.ts', ]), + loadTestEnvSource: "process.env.DB_TYPE = 'postgres';", + persistenceE2eSource: + "schema: database === 'postgres' ? process.env.DB_SCHEMA : undefined\ntype: database", rdbModuleSource: 'TypeOrmModule.forRootAsync({})', + testComposeSource: `name: barebones-test +services: + postgres: + image: postgres:17-alpine + ports: + - \${DB_TEST_HOST_PORT:-15432}:5432 + tmpfs: + - /var/lib/postgresql/data +`, }; } @@ -95,7 +119,9 @@ describe('scaffold config', () => { devDependencies: current.devDependencies, envExampleSource: current.envExampleSource, files: current.files, + loadTestEnvSource: current.loadTestEnvSource, rdbModuleSource: current.rdbModuleSource, + testComposeSource: current.testComposeSource, }; expect(scaffoldConsistencyIssues(config, state)).toContain( @@ -114,7 +140,9 @@ describe('scaffold config', () => { devDependencies: current.devDependencies, envExampleSource: current.envExampleSource, files: current.files, + loadTestEnvSource: current.loadTestEnvSource, rdbModuleSource: current.rdbModuleSource, + testComposeSource: current.testComposeSource, }; expect(scaffoldConsistencyIssues(config, state)).toContain( @@ -133,7 +161,9 @@ describe('scaffold config', () => { devDependencies: current.devDependencies, envExampleSource: current.envExampleSource, files: current.files, + loadTestEnvSource: current.loadTestEnvSource, rdbModuleSource: `${current.rdbModuleSource}\nPrismaClient`, + testComposeSource: current.testComposeSource, }; expect(scaffoldConsistencyIssues(config, state)).toContain( @@ -149,10 +179,115 @@ describe('scaffold config', () => { expect.arrayContaining([ 'missing database driver for typeorm/mysql: mysql2', '.env.example DB_TYPE does not match selected database: mysql', - 'docker-compose.yml does not provide selected database: mysql', + 'docker-compose.yml RDB does not match selection: expected mysql, found postgres', ]), ); }); + + it('ignores an image marker in a comment and rejects the actual unselected service', () => { + const config = parseScaffoldConfig({ rdb: { database: 'postgres', orm: 'typeorm' } }); + const current = typeOrmPostgresState(); + const state: ScaffoldState = { + activeScaffoldSource: current.activeScaffoldSource, + appModuleSource: current.appModuleSource, + composeSource: current.composeSource + .replace( + 'postgres:\n image: postgres:17-alpine', + 'mysql:\n # image: postgres:17-alpine\n image: mysql:8.4', + ) + .replace(' postgres:', ' mysql:'), + dependencies: current.dependencies, + devDependencies: current.devDependencies, + envExampleSource: current.envExampleSource, + files: current.files, + loadTestEnvSource: current.loadTestEnvSource, + rdbModuleSource: current.rdbModuleSource, + testComposeSource: current.testComposeSource, + }; + + expect(scaffoldConsistencyIssues(config, state)).toEqual( + expect.arrayContaining([ + 'docker-compose.yml RDB does not match selection: expected postgres, found mysql', + 'docker-compose.yml contains unselected RDB service: mysql', + ]), + ); + }); + + it('rejects an unselected database service left beside the selected service', () => { + const config = parseScaffoldConfig({ rdb: { database: 'postgres', orm: 'typeorm' } }); + const current = typeOrmPostgresState(); + const state: ScaffoldState = { + activeScaffoldSource: current.activeScaffoldSource, + appModuleSource: current.appModuleSource, + composeSource: current.composeSource.replace( + '\nvolumes:', + '\n mysql:\n image: mysql:8.4\nvolumes:', + ), + dependencies: current.dependencies, + devDependencies: current.devDependencies, + envExampleSource: current.envExampleSource, + files: current.files, + loadTestEnvSource: current.loadTestEnvSource, + rdbModuleSource: current.rdbModuleSource, + testComposeSource: current.testComposeSource, + }; + + expect(scaffoldConsistencyIssues(config, state)).toContain( + 'docker-compose.yml contains unselected RDB service: mysql', + ); + }); + + it('rejects an unselected ORM profile file left behind', () => { + const config = parseScaffoldConfig({ rdb: { database: 'postgres', orm: 'typeorm' } }); + const current = typeOrmPostgresState(); + const state: ScaffoldState = { + activeScaffoldSource: current.activeScaffoldSource, + appModuleSource: current.appModuleSource, + composeSource: current.composeSource, + dependencies: current.dependencies, + devDependencies: current.devDependencies, + envExampleSource: current.envExampleSource, + files: new Set([...current.files, 'prisma/schema.prisma']), + loadTestEnvSource: current.loadTestEnvSource, + rdbModuleSource: current.rdbModuleSource, + testComposeSource: current.testComposeSource, + }; + + expect(scaffoldConsistencyIssues(config, state)).toContain( + 'unselected ORM profile file is still present: prisma/schema.prisma', + ); + }); + + it('rejects a test compose database that differs from the selection', () => { + const config = parseScaffoldConfig({ rdb: { database: 'mysql', orm: 'typeorm' } }); + const current = typeOrmPostgresState(); + + expect(scaffoldConsistencyIssues(config, current)).toContain( + 'docker-compose.test.yml RDB does not match selection: expected mysql, found postgres', + ); + }); + + it('rejects PostgreSQL-only schema configuration passed to every TypeORM test database', () => { + const config = parseScaffoldConfig({ rdb: { database: 'postgres', orm: 'typeorm' } }); + const current = typeOrmPostgresState(); + const state: ScaffoldState = { + activeScaffoldSource: current.activeScaffoldSource, + appModuleSource: current.appModuleSource, + composeSource: current.composeSource, + dependencies: current.dependencies, + devDependencies: current.devDependencies, + envExampleSource: current.envExampleSource, + files: current.files, + loadTestEnvSource: current.loadTestEnvSource, + persistenceE2eSource: 'schema: process.env.DB_SCHEMA\ntype: database', + rdbModuleSource: current.rdbModuleSource, + testComposeSource: current.testComposeSource, + }; + + expect(scaffoldConsistencyIssues(config, state)).toContain( + 'TypeORM E2E must only pass DB_SCHEMA to PostgreSQL', + ); + }); }); describe('TypeORM migration consistency', () => { diff --git a/src/config/scaffold.config.ts b/src/config/scaffold.config.ts index 8251e60..034a914 100644 --- a/src/config/scaffold.config.ts +++ b/src/config/scaffold.config.ts @@ -1,3 +1,5 @@ +import { parse } from 'yaml'; + export const ORM_CHOICES = ['typeorm', 'prisma', 'mikroorm', 'drizzle'] as const; export const RDB_CHOICES = ['postgres', 'mysql', 'mariadb'] as const; @@ -19,7 +21,10 @@ export interface ScaffoldState { readonly devDependencies: ReadonlySet; readonly envExampleSource: string; readonly files: ReadonlySet; + readonly loadTestEnvSource: string; + readonly persistenceE2eSource?: string; readonly rdbModuleSource: string; + readonly testComposeSource: string; } interface TypeOrmMigrationState { @@ -130,12 +135,143 @@ const DATABASE_DRIVER_PACKAGES: Record = { - mariadb: 'image: mariadb:', - mysql: 'image: mysql:', - postgres: 'image: postgres:', +const DATABASE_IMAGES: Record = { + mariadb: 'mariadb:lts', + mysql: 'mysql:8.4', + postgres: 'postgres:17-alpine', +}; + +const DATABASE_PORTS: Record = { + mariadb: 3306, + mysql: 3306, + postgres: 5432, +}; + +const DATABASE_DATA_PATHS: Record = { + mariadb: '/var/lib/mysql', + mysql: '/var/lib/mysql', + postgres: '/var/lib/postgresql/data', }; +function record(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseCompose( + source: string, + path: string, +): { issues: string[]; root?: Record } { + try { + const root = record(parse(source)); + return root ? { issues: [], root } : { issues: [`${path} must contain a YAML object`] }; + } catch (error) { + const message = error instanceof Error ? error.message.split('\n')[0] : 'unknown parse error'; + return { issues: [`${path} is invalid YAML: ${message}`] }; + } +} + +function databaseServices(services: Record): RdbChoice[] { + return RDB_CHOICES.filter((database) => database in services); +} + +function scalarDescription(value: unknown): string { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : 'none'; +} + +function baseComposeIssues(config: ScaffoldConfig, source: string): string[] { + const parsed = parseCompose(source, 'docker-compose.yml'); + if (!parsed.root) return parsed.issues; + + const issues = [...parsed.issues]; + const services = record(parsed.root.services); + if (!services) return [...issues, 'docker-compose.yml services map is missing']; + + const selected = config.rdb.database; + const found = databaseServices(services); + const selectedService = record(services[selected]); + if (!selectedService) { + issues.push( + `docker-compose.yml RDB does not match selection: expected ${selected}, found ${found.join(', ') || 'none'}`, + ); + } else if (selectedService.image !== DATABASE_IMAGES[selected]) { + issues.push( + `docker-compose.yml ${selected} image does not match selection: expected ${DATABASE_IMAGES[selected]}, found ${scalarDescription(selectedService.image)}`, + ); + } + found + .filter((database) => database !== selected) + .forEach((database) => + issues.push(`docker-compose.yml contains unselected RDB service: ${database}`), + ); + + const app = record(services.app); + const dependsOn = record(app?.depends_on); + if (!dependsOn || !(selected in dependsOn)) { + issues.push(`docker-compose.yml app.depends_on does not include selected RDB: ${selected}`); + } + const environment = record(app?.environment); + const expectedEnvironment = { + DB_HOST: `\${DOCKER_DB_HOST:-${selected}}`, + DB_PORT: `\${DB_PORT:-${DATABASE_PORTS[selected]}}`, + DB_TYPE: selected, + }; + for (const [name, expected] of Object.entries(expectedEnvironment)) { + if (environment?.[name] !== expected) { + issues.push(`docker-compose.yml app ${name} does not match selection: expected ${expected}`); + } + } + return issues; +} + +function testComposeIssues(config: ScaffoldConfig, source: string): string[] { + const parsed = parseCompose(source, 'docker-compose.test.yml'); + if (!parsed.root) return parsed.issues; + + const issues = [...parsed.issues]; + if (parsed.root.name !== 'barebones-test') { + issues.push('docker-compose.test.yml must use project name: barebones-test'); + } + const services = record(parsed.root.services); + if (!services) return [...issues, 'docker-compose.test.yml services map is missing']; + + const selected = config.rdb.database; + const found = databaseServices(services); + const selectedService = record(services[selected]); + if (!selectedService) { + issues.push( + `docker-compose.test.yml RDB does not match selection: expected ${selected}, found ${found.join(', ') || 'none'}`, + ); + } else if (selectedService.image !== DATABASE_IMAGES[selected]) { + issues.push( + `docker-compose.test.yml ${selected} image does not match selection: expected ${DATABASE_IMAGES[selected]}, found ${scalarDescription(selectedService.image)}`, + ); + } + found + .filter((database) => database !== selected) + .forEach((database) => + issues.push(`docker-compose.test.yml contains unselected RDB service: ${database}`), + ); + const ports = selectedService?.ports; + const expectedPort = `\${DB_TEST_HOST_PORT:-15432}:${DATABASE_PORTS[selected]}`; + if (!Array.isArray(ports) || !ports.includes(expectedPort)) { + issues.push(`docker-compose.test.yml port does not match selection: expected ${expectedPort}`); + } + const tmpfs = selectedService?.tmpfs; + if (!Array.isArray(tmpfs) || !tmpfs.includes(DATABASE_DATA_PATHS[selected])) { + issues.push( + `docker-compose.test.yml must use tmpfs for selected RDB data: ${DATABASE_DATA_PATHS[selected]}`, + ); + } + if (selectedService && 'volumes' in selectedService) { + issues.push('docker-compose.test.yml selected RDB must not use a persistent volume'); + } + return issues; +} + export class ScaffoldConfigError extends Error { constructor(message: string) { super(message); @@ -288,6 +424,16 @@ export function scaffoldConsistencyIssues(config: ScaffoldConfig, state: Scaffol } } + const selectedFiles = new Set(contract.requiredFiles); + for (const orm of ORM_CHOICES) { + if (orm === config.rdb.orm) continue; + for (const file of ORM_PROFILE_CONTRACTS[orm].requiredFiles) { + if (!selectedFiles.has(file) && state.files.has(file)) { + issues.push(`unselected ORM profile file is still present: ${file}`); + } + } + } + for (const orm of ORM_CHOICES) { const marker = ORM_PROFILE_CONTRACTS[orm].appModuleMarker; const markerIsPresent = state.rdbModuleSource.includes(marker); @@ -305,10 +451,27 @@ export function scaffoldConsistencyIssues(config: ScaffoldConfig, state: Scaffol issues.push(`.env.example DB_TYPE does not match selected database: ${config.rdb.database}`); } - const composeMarker = DATABASE_COMPOSE_MARKERS[config.rdb.database]; - if (!state.composeSource.includes(composeMarker)) { - issues.push(`docker-compose.yml does not provide selected database: ${config.rdb.database}`); + if (!state.loadTestEnvSource.includes(`process.env.DB_TYPE = '${config.rdb.database}'`)) { + issues.push( + `test/load-test-env.ts DB_TYPE does not match selection: expected ${config.rdb.database}`, + ); } + if (config.rdb.orm === 'typeorm') { + if ( + !state.persistenceE2eSource?.includes( + "schema: database === 'postgres' ? process.env.DB_SCHEMA : undefined", + ) + ) { + issues.push('TypeORM E2E must only pass DB_SCHEMA to PostgreSQL'); + } + if (!state.persistenceE2eSource?.includes('type: database')) { + issues.push('TypeORM E2E must use the validated test database type'); + } + } + + issues.push(...baseComposeIssues(config, state.composeSource)); + issues.push(...testComposeIssues(config, state.testComposeSource)); + return issues; } diff --git a/src/config/typeorm-rdb-generator.spec.ts b/src/config/typeorm-rdb-generator.spec.ts index 39dfd5b..945bad0 100644 --- a/src/config/typeorm-rdb-generator.spec.ts +++ b/src/config/typeorm-rdb-generator.spec.ts @@ -1,7 +1,12 @@ +import { readFileSync } from 'node:fs'; + +import { parse } from 'yaml'; + import { materializeTypeOrmCompose, materializeTypeOrmEnv, renderActiveScaffold, + renderTestCompose, selectedTypeOrmDriver, } from './typeorm-rdb-generator.js'; @@ -71,7 +76,63 @@ describe('TypeORM RDB generator', () => { it('refuses an unknown compose shape instead of partially rewriting it', () => { expect(() => materializeTypeOrmCompose('services: {}', 'postgres')).toThrow( - 'RDB service block was not found', + 'must contain exactly one primary RDB service', + ); + }); + + it('preserves every existing comment while materializing the checked-in compose file', () => { + const source = readFileSync('docker-compose.yml', 'utf8'); + const comments = source + .split('\n') + .filter((line) => line.includes('#')) + .map((line) => line.trim()); + + expect(comments).toHaveLength(13); + const rendered = materializeTypeOrmCompose(source, 'mysql'); + expect(comments.filter((comment) => !rendered.includes(comment))).toEqual([]); + }); + + it('preserves a service inserted between the RDB and Redis', () => { + const withPgAdmin = compose.replace( + '\n redis:', + '\n pgadmin:\n image: dpage/pgadmin4:9.8\n\n redis:', + ); + + const rendered = materializeTypeOrmCompose(withPgAdmin, 'mysql'); + expect(rendered).toContain('pgadmin:'); + expect(rendered).toContain('image: dpage/pgadmin4:9.8'); + }); + + it('preserves top-level volumes when the RDB is the final service', () => { + const rdbBlock = compose.match(/\n {2}postgres:\n[\s\S]*?(?=\n {2}redis:)/)?.[0]; + expect(rdbBlock).toBeDefined(); + const rdbLast = compose + .replace(rdbBlock ?? '', '') + .replace('\nvolumes:', `${rdbBlock}\nvolumes:`); + + const rendered = materializeTypeOrmCompose(rdbLast, 'mysql'); + expect(rendered).toContain('\nvolumes:\n'); + expect(rendered).toContain('mysql-data:'); + }); + + it.each([ + ['postgres', 'postgres:17-alpine', 5432], + ['mysql', 'mysql:8.4', 3306], + ['mariadb', 'mariadb:lts', 3306], + ] as const)('renders an isolated %s test database', (database, image, containerPort) => { + const rendered = parse(renderTestCompose(database)) as { + name: string; + services: Record; + }; + + expect(rendered.name).toBe('barebones-test'); + expect(rendered.services[database]).toMatchObject({ + image, + ports: [`\${DB_TEST_HOST_PORT:-15432}:${containerPort}`], + }); + expect(rendered.services[database].tmpfs).toHaveLength(1); + expect(renderTestCompose(database)).toContain( + `- '\${DB_TEST_HOST_PORT:-15432}:${containerPort}'`, ); }); }); diff --git a/src/config/typeorm-rdb-generator.ts b/src/config/typeorm-rdb-generator.ts index 0b6580d..33316ef 100644 --- a/src/config/typeorm-rdb-generator.ts +++ b/src/config/typeorm-rdb-generator.ts @@ -1,7 +1,10 @@ +import type { Document, YAMLMap } from 'yaml'; +import { isMap, parseDocument, Scalar } from 'yaml'; + import type { RdbChoice, ScaffoldConfig } from './scaffold.config.js'; interface DatabaseMaterialization { - readonly composeService: string; + readonly composeService: Record; readonly dockerHost: string; readonly driverPackage: 'mysql2' | 'pg'; readonly port: number; @@ -9,99 +12,157 @@ interface DatabaseMaterialization { const MATERIALIZATIONS: Record = { mariadb: { - composeService: ` mariadb: - image: mariadb:lts - restart: unless-stopped - environment: - MARIADB_DATABASE: \${DB_DATABASE:-app} - MARIADB_USER: \${DB_USERNAME:-app} - MARIADB_PASSWORD: \${DB_PASSWORD:-app} - MARIADB_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-root} - ports: - - '\${DB_HOST_PORT:-3306}:3306' - volumes: - - mariadb-data:/var/lib/mysql - healthcheck: - test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] - interval: 10s - timeout: 5s - retries: 10 -`, + composeService: { + image: 'mariadb:lts', + restart: 'unless-stopped', + environment: { + MARIADB_DATABASE: '${DB_DATABASE:-app}', + MARIADB_USER: '${DB_USERNAME:-app}', + MARIADB_PASSWORD: '${DB_PASSWORD:-app}', + MARIADB_ROOT_PASSWORD: '${DB_ROOT_PASSWORD:-root}', + }, + ports: ['${DB_HOST_PORT:-3306}:3306'], + volumes: ['mariadb-data:/var/lib/mysql'], + healthcheck: { + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'], + interval: '10s', + timeout: '5s', + retries: 10, + }, + }, dockerHost: 'mariadb', driverPackage: 'mysql2', port: 3306, }, mysql: { - composeService: ` mysql: - image: mysql:8.4 - restart: unless-stopped - environment: - MYSQL_DATABASE: \${DB_DATABASE:-app} - MYSQL_USER: \${DB_USERNAME:-app} - MYSQL_PASSWORD: \${DB_PASSWORD:-app} - MYSQL_ROOT_PASSWORD: \${DB_ROOT_PASSWORD:-root} - ports: - - '\${DB_HOST_PORT:-3306}:3306' - volumes: - - mysql-data:/var/lib/mysql - healthcheck: - test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -u"$$MYSQL_USER" --password="$$MYSQL_PASSWORD"'] - interval: 10s - timeout: 5s - retries: 10 -`, + composeService: { + image: 'mysql:8.4', + restart: 'unless-stopped', + environment: { + MYSQL_DATABASE: '${DB_DATABASE:-app}', + MYSQL_USER: '${DB_USERNAME:-app}', + MYSQL_PASSWORD: '${DB_PASSWORD:-app}', + MYSQL_ROOT_PASSWORD: '${DB_ROOT_PASSWORD:-root}', + }, + ports: ['${DB_HOST_PORT:-3306}:3306'], + volumes: ['mysql-data:/var/lib/mysql'], + healthcheck: { + test: [ + 'CMD-SHELL', + 'mysqladmin ping -h 127.0.0.1 -u"$$MYSQL_USER" --password="$$MYSQL_PASSWORD"', + ], + interval: '10s', + timeout: '5s', + retries: 10, + }, + }, dockerHost: 'mysql', driverPackage: 'mysql2', port: 3306, }, postgres: { - composeService: ` postgres: - image: postgres:17-alpine - restart: unless-stopped - environment: - POSTGRES_DB: \${DB_DATABASE:-app} - POSTGRES_USER: \${DB_USERNAME:-app} - POSTGRES_PASSWORD: \${DB_PASSWORD:-app} - ports: - - '\${DB_HOST_PORT:-5432}:5432' - volumes: - - postgres-data:/var/lib/postgresql/data - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U \${DB_USERNAME:-app} -d \${DB_DATABASE:-app}'] - interval: 10s - timeout: 5s - retries: 10 -`, + composeService: { + image: 'postgres:17-alpine', + restart: 'unless-stopped', + environment: { + POSTGRES_DB: '${DB_DATABASE:-app}', + POSTGRES_USER: '${DB_USERNAME:-app}', + POSTGRES_PASSWORD: '${DB_PASSWORD:-app}', + }, + ports: ['${DB_HOST_PORT:-5432}:5432'], + volumes: ['postgres-data:/var/lib/postgresql/data'], + healthcheck: { + test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME:-app} -d ${DB_DATABASE:-app}'], + interval: '10s', + timeout: '5s', + retries: 10, + }, + }, dockerHost: 'postgres', driverPackage: 'pg', port: 5432, }, }; +const RDB_SERVICES = new Set(['postgres', 'mysql', 'mariadb']); + +function requiredMap(value: unknown, name: string): YAMLMap { + if (!isMap(value)) throw new Error(`docker-compose.yml ${name} map was not found`); + return value; +} + +function replaceMapEntry( + document: Document, + map: YAMLMap, + currentKey: string, + nextKey: string, + value: unknown, +): void { + const pair = map.items.find((item) => String(item.key) === currentKey); + if (!pair) throw new Error(`docker-compose.yml ${currentKey} entry was not found`); + pair.key = document.createNode(nextKey); + pair.value = document.createNode(value); +} + +function renameMapEntry( + document: Document, + map: YAMLMap, + currentKey: string, + nextKey: string, +): void { + const pair = map.items.find((item) => String(item.key) === currentKey); + if (!pair) throw new Error(`docker-compose.yml ${currentKey} entry was not found`); + pair.key = document.createNode(nextKey); +} + export function materializeTypeOrmCompose(source: string, database: RdbChoice): string { const selected = MATERIALIZATIONS[database]; - const serviceBlock = /^ {2}(?:postgres|mysql|mariadb):\n[\s\S]*?(?=\n {2}redis:)/m; + const document = parseDocument(source); + if (document.errors.length > 0) throw new Error(`docker-compose.yml is invalid YAML`); - if (!serviceBlock.test(source)) { - throw new Error('docker-compose.yml RDB service block was not found'); + const services = requiredMap(document.get('services', true), 'services'); + const currentDatabases = services.items + .map((item) => String(item.key)) + .filter((key): key is RdbChoice => RDB_SERVICES.has(key as RdbChoice)); + if (currentDatabases.length !== 1) { + throw new Error('docker-compose.yml must contain exactly one primary RDB service'); } - return source - .replace(serviceBlock, () => selected.composeService.trimEnd()) - .replace( - / {6}(?:postgres|mysql|mariadb):\n {8}condition: service_healthy/, - ` ${selected.dockerHost}:\n condition: service_healthy`, - ) - .replace(/ {6}DB_TYPE: (?:postgres|mysql|mariadb)/, ` DB_TYPE: ${database}`) - .replace( - / {6}DB_HOST: \$\{DOCKER_DB_HOST:-(?:postgres|mysql|mariadb)\}/, - ` DB_HOST: \${DOCKER_DB_HOST:-${selected.dockerHost}}`, - ) - .replace( - / {6}DB_PORT: \$\{DB_PORT:-(?:5432|3306)\}/, - ` DB_PORT: \${DB_PORT:-${selected.port}}`, - ) - .replace(/ {2}(?:postgres|mysql|mariadb)-data:\n/, ` ${database}-data:\n`); + replaceMapEntry(document, services, currentDatabases[0], database, selected.composeService); + + const dependsOn = requiredMap( + document.getIn(['services', 'app', 'depends_on'], true), + 'depends_on', + ); + replaceMapEntry(document, dependsOn, currentDatabases[0], database, { + condition: 'service_healthy', + }); + document.setIn(['services', 'app', 'environment', 'DB_TYPE'], database); + document.setIn( + ['services', 'app', 'environment', 'DB_HOST'], + `\${DOCKER_DB_HOST:-${selected.dockerHost}}`, + ); + document.setIn(['services', 'app', 'environment', 'DB_PORT'], `\${DB_PORT:-${selected.port}}`); + + const volumes = requiredMap(document.get('volumes', true), 'volumes'); + renameMapEntry(document, volumes, `${currentDatabases[0]}-data`, `${database}-data`); + return document.toString({ lineWidth: 0 }); +} + +export function renderTestCompose(database: RdbChoice): string { + const selected = MATERIALIZATIONS[database]; + const document = parseDocument('name: barebones-test\nservices:\n placeholder: {}\n'); + const service = structuredClone(selected.composeService); + delete service.restart; + delete service.volumes; + service.ports = [`\${DB_TEST_HOST_PORT:-15432}:${selected.port}`]; + service.tmpfs = [database === 'postgres' ? '/var/lib/postgresql/data' : '/var/lib/mysql']; + document.setIn(['services', database], document.createNode(service)); + document.deleteIn(['services', 'placeholder']); + const publishedPort = new Scalar(`\${DB_TEST_HOST_PORT:-15432}:${selected.port}`); + publishedPort.type = Scalar.QUOTE_SINGLE; + document.setIn(['services', database, 'ports', 0], publishedPort); + return document.toString({ lineWidth: 0 }); } export function materializeTypeOrmEnv(source: string, database: RdbChoice): string { diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 8ed841a..1112e01 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -6,7 +6,7 @@ import { AppModule } from '../src/app.module.js'; import { configureHttpApp } from '../src/app.setup.js'; describe('AppController (e2e)', () => { - let app: INestApplication; + let app: INestApplication | undefined; let baseUrl: string; beforeAll(async () => { @@ -22,7 +22,7 @@ describe('AppController (e2e)', () => { }); afterAll(async () => { - await app.close(); + await app?.close(); }); /** diff --git a/test/load-test-env.ts b/test/load-test-env.ts index c3def45..1d74c06 100644 --- a/test/load-test-env.ts +++ b/test/load-test-env.ts @@ -23,11 +23,11 @@ process.env.MONGODB_USERNAME ??= 'app'; process.env.MONGODB_PASSWORD ??= 'app'; process.env.MONGODB_DATABASE ??= 'app'; process.env.MONGODB_AUTH_SOURCE ??= 'admin'; -// E2E는 인메모리 DB 대신 docker-compose의 실제 PostgreSQL을 사용한다. -// compose 안에서는 이미 DB_HOST=postgres가 주입되고, 호스트 실행은 localhost를 쓴다. +// E2E는 인메모리 DB 대신 docker-compose.test.yml의 선택된 실제 RDB를 사용한다. +// 테스트 Compose는 15432를 publish하고, 호스트 실행은 localhost를 쓴다. process.env.DB_TYPE = 'postgres'; process.env.DB_HOST ??= 'localhost'; -process.env.DB_PORT ??= '5432'; +process.env.DB_PORT ??= '15432'; process.env.DB_USERNAME ??= 'app'; process.env.DB_PASSWORD ??= 'app'; process.env.DB_DATABASE ??= 'app'; diff --git a/test/persistence.e2e-spec.ts b/test/persistence.e2e-spec.ts index ba7f39d..8d31186 100644 --- a/test/persistence.e2e-spec.ts +++ b/test/persistence.e2e-spec.ts @@ -34,7 +34,7 @@ class ProbeRow { @PrimaryGeneratedColumn('increment') id!: number; - @Column({ type: 'text', unique: true }) + @Column({ length: 255, type: 'varchar', unique: true }) name!: string; @Column({ nullable: true, type: 'text' }) @@ -47,6 +47,19 @@ class ProbeRow { const PROBE_REPOSITORY = createRepositoryToken('PROBE_REPOSITORY', ProbeRow); +type TestDatabase = 'mariadb' | 'mysql' | 'postgres'; + +function testDatabase(): TestDatabase { + switch (process.env.DB_TYPE) { + case 'mariadb': + case 'mysql': + case 'postgres': + return process.env.DB_TYPE; + default: + throw new Error(`Unsupported test database: ${process.env.DB_TYPE ?? 'unset'}`); + } +} + /** * 도메인 Repository의 형태. * @@ -125,13 +138,14 @@ class ProbeModule {} describe('persistence port wiring (e2e)', () => { let repository: ProbeRepository; - let close: () => Promise; + let close: (() => Promise) | undefined; beforeAll(async () => { + const database = testDatabase(); const moduleRef = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema }), - // docker-compose의 실제 PostgreSQL에 픽스처 엔티티를 올린다. + // docker-compose.test.yml의 선택된 실제 RDB에 픽스처 엔티티를 올린다. // 어댑터의 SQL 번역과 드라이버 에러 변환을 운영 기본 드라이버에서 검증한다. TypeOrmModule.forRoot({ database: process.env.DB_DATABASE, @@ -140,9 +154,9 @@ describe('persistence port wiring (e2e)', () => { logging: false, password: process.env.DB_PASSWORD, port: Number(process.env.DB_PORT), - schema: process.env.DB_SCHEMA, + schema: database === 'postgres' ? process.env.DB_SCHEMA : undefined, synchronize: true, - type: 'postgres', + type: database, username: process.env.DB_USERNAME, }), ProbeModule, @@ -155,7 +169,7 @@ describe('persistence port wiring (e2e)', () => { }); afterAll(async () => { - await close(); + await close?.(); }); beforeEach(async () => {