Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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도 실행한다.
7 changes: 6 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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에서 교체한다.

## 생성 시 선택

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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입니다.
19 changes: 19 additions & 0 deletions docker-compose.test.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions scripts/check-scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 3 additions & 2 deletions scripts/select-scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
materializeTypeOrmCompose,
materializeTypeOrmEnv,
renderActiveScaffold,
renderTestCompose,
selectedTypeOrmDriver,
} from '../src/config/typeorm-rdb-generator.js';

Expand Down Expand Up @@ -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}를 사용한다.`,
Expand Down Expand Up @@ -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'),
Expand Down
13 changes: 0 additions & 13 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -72,17 +70,6 @@ const {
}),
RdbDatabaseModule,
...(mongodbEnabled ? [MongoDatabaseModule] : []),
...(bullmqEnabled
? [
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
connection: buildBullConnectionOptions(configService),
prefix: configService.get<string>('BULLMQ_PREFIX') ?? 'app',
}),
}),
]
: []),
HealthModule,
...(metricsEnabled ? [MetricsModule] : []),
RedisModule,
Expand Down
139 changes: 137 additions & 2 deletions src/config/scaffold.config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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
`,
};
}

Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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', () => {
Expand Down
Loading
Loading