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 5786c16..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 ``` @@ -31,6 +33,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/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/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/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/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, }), 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 () => {