studio-v1.yaml 19개 operation 중 Plan 01이 남긴 17개를 구현한다. 문서 CRUD,
검증·미리보기, 게시, Asset. 이로써 studio-v1은 19/19다.
Plan 01이 Plan 02로 미룬 생성기 union 차단 요인
- 계약 원본은 그대로 두고 prepareStudioCodegenSpec이 생성 직전에 사본을 파생시킨다.
oneOf+discriminator를 가진 스키마의 하위 타입에 x-implements를 주입하고, union 자체는
생성을 억제한 뒤 같은 package에 Jackson 다형성 인터페이스를 계약에서 파생해 써 넣는다
- 파생 규칙을 계약의 oneOf/discriminator.mapping에서 읽으므로 union 목록을 손으로
관리하지 않는다. 계약에 union이 늘면 따라온다
- openApiNullable=false. JsonNullable을 읽는 모듈은 Jackson 2용인데 이 앱의 HTTP
변환기는 Jackson 3(tools.jackson)다 — 등록될 수 없어 직렬화가 POJO로 새고
역직렬화가 깨진다. 해당 필드는 계약상 required라 "없음"과 "null"을 구분할 필요도 없다
- 모든 분기가 type:string인 이름 없는 oneOf는 접는다. 안 접으면 필드 0개 껍데기
클래스가 나와 slug가 {}로 직렬화된다
- oneOf:[X,null]도 접는다. 그대로 두면 같은 모양의 래퍼 타입이 7벌 더 생긴다
- StudioContractUnionJacksonTest가 이 배선을 지킨다. 파생이 깨지면 컴파일이 깨진다
설계 스키마의 구멍 — V8__techlog_studio_working_copy.sql
V7(설계 패키지 database/V1__init.sql)은 유형마다 다른 물리 모델인데 계약은 네 유형을
공통 base + 유형별 확장이라는 하나의 편집 흐름으로 다룬다. 계약이 요구하는데 없던 것:
- document.summary / case_detail.environment,reproduction / reference_detail.rules,examples
- open_question.options,resolution_evidence_target_id,resolution_link_label
- project_decision.title,slug,summary,primary_topic_id
- problem/conclusion/scope_summary/statement가 varchar라 계약의 100000자를 담을 수 없어 text로 넓힘
- project_decision.project_id NOT NULL은 계약이 명시적으로 허용한 초안 저장을
구조적으로 막고 있었다(게시 필수 여부는 검증이 판단한다) — 풀었다
- studio_relation: 계약의 relations[]는 네 유형 공통이고 항목마다 자체 id와 reason이
있다. document_relation은 복합 PK라 둘 다 없고 문서끼리만 성립한다.
(source_kind, source_id) 다형 참조는 studio_validation/studio_preview가 이미 쓰는 방식이다
영속은 JdbcClient
spec §8.3은 쓰기에 JPA @Version을 적었지만 이 네 aggregate는 Studio 저장 경로에서만
쓰이고 UPDATE ... WHERE version = :expectedVersion의 갱신 행 수가 정확히 같은 의미를
준다. 여덟 개 넘는 테이블에 엔티티를 세우는 비용에 상응하는 이득이 없다. 포트 계약이
같으므로 나중에 JPA가 필요하면 어댑터만 바뀐다.
nextAction/dependencyRevision 계산은 SQL 한 벌(StudioDocumentSql)
목록과 상세가 각자 계산하면 "목록에선 게시하라더니 열어보니 검증하라"가 된다.
계약의 nextAction 필터도 SQL이라야 페이지네이션을 깨지 않고 걸 수 있다.
렌더러 (ADR-005)
- commonmark + GFM 확장. 설계 05장 §16대로 라이브러리는 render 패키지 밖으로 안 나간다.
프론트가 remark 계열로 같은 CommonMark+GFM 기준을 쓰므로 동등성이 유지된다
- ::: directive는 줄 단위 스캔이다. v1 문법에서 중첩이 없고 줄 맨 앞에서만 열린다.
코드 펜스 안의 :::는 directive로 보지 않는다
- 컨테이너/leaf 판정은 닫는 줄이 실제로 있는지로 한다. 이름 목록으로 정하면 directive를
더할 때마다 목록을 고쳐야 하고, "닫는 줄 없으면 문서 끝까지"면 닫기를 빠뜨린
directive 하나가 뒤 내용을 통째로 삼킨다
- 계약이 표현 못 하는 것은 조용히 바꾸지 않고 경고로 남긴다 — 수평선, 머리글 없는 표,
알 수 없는 directive, 미해결 asset key(경로를 지어내지 않고 버린다)
- RenderModelPort 구현이 inbound web에 있다. 렌더 모델은 계약 DTO이고 그 타입을 소유한
모듈이 거기다. application에 같은 모양을 한 벌 더 두면 두 정의가 갈라진다
검증 체인
판정 기준은 하나다 — 이 편집본으로 계약이 요구하는 PublicRenderModel을 만들 수 있는가.
각 규칙은 렌더 모델의 required/minLength/minItems에서 나온다. 다른 기준을 쓰면 검증을
통과한 문서가 렌더 단계에서 계약을 위반한다. 첫 오류에서 멈추지 않고 끝까지 모은다.
게시 (spec §7.5 20단계)
- Snapshot의 렌더 모델은 게시 시점에 다시 렌더링하지 않고 사용자가 확인한 미리보기의
것을 그대로 쓴다. 다시 렌더링하면 승인한 화면과 공개된 화면이 달라질 수 있다
- 단계별 실패가 서로 다른 계약 코드로 나간다. DOCUMENT_VALIDATION_FAILED(지금 검증하면
실패)와 VALIDATION_STALE(통과했으나 전제가 바뀜)은 다른 사건이고 할 일도 다르다
- 게시 취소는 route도 Snapshot도 지우지 않는다. 지우면 공개된 링크가 끊긴다
Asset
- 확장자와 클라이언트 Content-Type을 신뢰하지 않고 파일 시작 바이트로 판정한다.
모르는 형식은 저장하지 않고 415로 거절한다
- 바이너리는 기존 object storage 어댑터에 위임한다(spec §9). ObjectStoragePort는
deprecated지만 이 저장소에서 실제 구현이 붙어 있는 유일한 포트다 — 선택을 브리지
한 클래스에 가뒀다. 저장 백엔드가 없는 배포는 업로드·삭제만 503이고 나머지는 동작한다
- 공개 이력이 있거나 사용 중인 Asset은 hard delete하지 않는다
검증 — "통과하는데 동작 안 함"을 세 겹으로 막았다
- StudioContractDriftTest에 반대 방향(계약 → published)을 추가했다. 기존 한 방향은
사라진 operation을 못 잡는다. 양방향 모두 실제로 RED가 되는 것을 확인했다
- postgresqlTechLogStudioPersistenceIntegrationTest 신규 11개. 이 저장소의 check는
Testcontainers를 돌리지 않아 이 테스트가 없으면 SQL이 한 번도 실행되지 않는다.
첫 실행에서 실제 결함을 잡았다: fk_publication_latest_event의 지연 검사는 트랜잭션
끝에 일어나므로 autocommit이면 첫 INSERT에서 위반된다 → 어댑터가 진입 시 활성
트랜잭션을 확인하고 아니면 원인을 그대로 말하며 실패한다
- 실제 앱 부팅으로 두 건을 더 잡았다. check에 전체 앱 부팅 테스트가 없어 생긴 구멍이다
1. 생성자 모호성 — 프로덕션/테스트 두 생성자에 표시가 없어 기본 생성자를 찾다 실패
2. final 클래스 + AOP — @RequiresPermission은 CGLIB 프록시를 쓰는데 final은 subclass
불가. 템플릿의 NotificationDispatchUseCase가 final이면서 무사한 것은 그 능력이 꺼진
배포에서 빈으로 등록되지 않아서다. Studio use case는 항상 등록된다
가드레일이 잡은 것
- MUTATING_USE_CASES_DECLARE_REQUIRED_PERMISSION → studio:write 부여, role 매핑은
프로파일에. application.yml의 role-permissions:{} 기준선은 SampleRemovalSmokeContractTest가 지킨다
- NO_CONTEXT_DEPENDS_ON_STUDIO_FACADE 223건 → 어댑터 패키지를 persistence.techlog.studio.*로
옮겼다. 규칙을 고치지 않았고, 그 이름이 우회가 아니라 더 정확하다
- verifyEnvKeys → 새 APP_ 키 4건 등록
범위
studio-v1의 19개 전부. public-v1(18) / studio-management-v1(79)은 spec §2.2가 선언한
out of scope다 — 전자는 소비자가 아직 없고 후자는 secondary capability 보존 계약이다.
검증: ./gradlew check BUILD SUCCESSFUL (245 task), 전체 3,721 테스트 실패 0,
techlog PostgreSQL 통합 테스트 3종 통과, 실제 앱 부팅 확인.
AGENTS.md의 commit 정책은 human-only다. 이 커밋은 사용자가 "전부 커밋하고 머지
진행하세요"로 명시적으로 지시해 예외로 수행한다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
11 KiB
Groovy
196 lines
11 KiB
Groovy
// JPA persistence adapter — merged RDBMS base + PostgreSQL vendor module.
|
|
// Owns JPA entities, Spring Data repositories, mappers, transaction/audit/lock/outbox port
|
|
// implementations, and the vendor-neutral SPI interfaces (OutboxClaimRepository /
|
|
// SqlStateErrorMapping). The PostgreSQL driver, flyway-database-postgresql dialect, and vendor
|
|
// Flyway migrations live only under the .postgresql subpackage (ArchUnit keeps the base neutral).
|
|
sourceSets {
|
|
postgresqlIntegrationTest {
|
|
java.setSrcDirs(['src/postgresqlIntegrationTest/java'])
|
|
resources.setSrcDirs(['src/postgresqlIntegrationTest/resources'])
|
|
compileClasspath += sourceSets.main.output
|
|
runtimeClasspath += output + compileClasspath
|
|
}
|
|
}
|
|
|
|
configurations {
|
|
postgresqlIntegrationTestImplementation.extendsFrom testImplementation
|
|
postgresqlIntegrationTestCompileOnly.extendsFrom testCompileOnly
|
|
postgresqlIntegrationTestRuntimeOnly.extendsFrom testRuntimeOnly
|
|
postgresqlIntegrationTestAnnotationProcessor.extendsFrom testAnnotationProcessor
|
|
}
|
|
|
|
ext.jpaPostgreSqlEvidenceImage = 'postgres:16-alpine'
|
|
|
|
dependencies {
|
|
implementation project(':application-core')
|
|
implementation project(':shared-contract')
|
|
|
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
|
// Studio 편집본의 jsonb 컬럼(reference_detail.rules/examples, open_question.options,
|
|
// project_decision.consequences, applies_to/excluded_scope)을 읽고 쓰려면 이 모듈에 JSON 매퍼가
|
|
// 필요하다. Jackson 2 databind 가 data-jpa 경유로 이미 classpath 에 딸려오지만 그건 선언하지 않은
|
|
// 우연한 가용성이고, 이 저장소는 dependency locking 을 쓴다 — 앱의 다른 계층과 같은
|
|
// Jackson 3(tools.jackson)을 명시적으로 선언한다.
|
|
implementation 'org.springframework.boot:spring-boot-starter-jackson'
|
|
// feature-distributed-lock-contract: Spring Integration JDBC LockRegistry backs the
|
|
// multi-instance distributedLockProvider. Version managed by Spring Boot BOM.
|
|
implementation 'org.springframework.integration:spring-integration-jdbc'
|
|
|
|
// Vendor (PostgreSQL): Flyway migration API + PostgreSQL driver/dialect. Used only by the
|
|
// .postgresql subpackage; the RDBMS base stays vendor-neutral (PERSISTENCE_RDBMS_STAYS_VENDOR_NEUTRAL).
|
|
implementation 'org.springframework.boot:spring-boot-starter-flyway'
|
|
runtimeOnly 'org.postgresql:postgresql'
|
|
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
|
|
|
|
// Vendor (H2): the local-profile driver. Used only by the .h2 subpackage, which reaches it
|
|
// through JDBC/JPA rather than by importing org.h2 types — the same shape as the PostgreSQL
|
|
// driver above. Not `developmentOnly`: local is a deployable profile of this artifact, and the
|
|
// vendor selector, not the packaging, decides which driver a deployment loads.
|
|
runtimeOnly 'com.h2database:h2'
|
|
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
|
|
|
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-postgresql'
|
|
postgresqlIntegrationTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
|
postgresqlIntegrationTestRuntimeOnly 'org.postgresql:postgresql'
|
|
}
|
|
|
|
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
|
|
|
def registerPostgreSqlReadinessTest = { String taskName, String testClass ->
|
|
tasks.register(taskName, Test) {
|
|
group = 'verification'
|
|
description = "Runs the no-skip real PostgreSQL readiness scenario ${testClass}."
|
|
testClassesDirs = sourceSets.postgresqlIntegrationTest.output.classesDirs
|
|
classpath = sourceSets.postgresqlIntegrationTest.runtimeClasspath
|
|
useJUnitPlatform()
|
|
filter {
|
|
includeTestsMatching testClass
|
|
}
|
|
failOnNoDiscoveredTests = true
|
|
outputs.upToDateWhen { false }
|
|
jvmArgs(
|
|
'-Duser.timezone=UTC',
|
|
"-Djpa.evidence.postgresql.image=${jpaPostgreSqlEvidenceImage}")
|
|
}
|
|
}
|
|
|
|
def postgresqlLifecycleIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlLifecycleIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlLifecycleIntegrationTest')
|
|
def postgresqlSecurityBaselineIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlSecurityBaselineIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlSecurityBaselineIntegrationTest')
|
|
def postgresqlMigrationIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlMigrationIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlMigrationIntegrationTest')
|
|
def postgresqlTransactionIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlTransactionIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlTransactionIntegrationTest')
|
|
def postgresqlAggregateIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlAggregateIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlAggregateIntegrationTest')
|
|
def postgresqlQueryIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlQueryIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlQueryIntegrationTest')
|
|
def postgresqlIdempotencyIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlIdempotencyIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlIdempotencyIntegrationTest')
|
|
def postgresqlOutboxStorageIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlOutboxStorageIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxStorageIntegrationTest')
|
|
def postgresqlOutboxPollingIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlOutboxPollingIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlOutboxPollingIntegrationTest')
|
|
def postgresqlInboxIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlInboxIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlInboxIntegrationTest')
|
|
def postgresqlFileserverMigrationIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlFileserverMigrationIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMigrationIntegrationTest')
|
|
def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlFileserverMetadataIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverMetadataStoreIntegrationTest')
|
|
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlFileserverReclamationIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
|
|
def postgresqlTechLogSchemaMigrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlTechLogSchemaMigrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.techlog.TechLogSchemaMigrationTest')
|
|
// Task 9 (listStudioCatalog): JdbcCatalogQueryAdapterTest has no @SpringBootConfiguration to hang a
|
|
// @SpringBootTest off in this module (same reason as postgresqlTechLogSchemaMigrationTest above), so it
|
|
// needs its own opt-in Testcontainers task rather than reusing an existing one.
|
|
def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlTechLogCatalogQueryIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
|
|
|
|
// 슬라이스 2~5: Studio 영속 경로 전체(편집본 4종 왕복, 낙관적 잠금, union 목록, 의존 해석,
|
|
// validation/preview artifact, 게시 20단계, 게시 취소, Asset)를 실제 PostgreSQL 위에서 돌린다.
|
|
// 표준 check 는 Testcontainers 를 돌리지 않으므로, 이 태스크가 없으면 그 SQL 은 한 번도 실행되지
|
|
// 않은 채로 빌드가 통과한다.
|
|
def postgresqlTechLogStudioPersistenceIntegrationTest = registerPostgreSqlReadinessTest(
|
|
'postgresqlTechLogStudioPersistenceIntegrationTest',
|
|
'dev.caskeleton.adapter.outbound.persistence.techlog.studio.StudioPersistenceIntegrationTest')
|
|
|
|
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
|
|
group = 'verification'
|
|
description = 'Rejects concatenated SQL construction and non-parameterized PostgreSQL timeout configuration.'
|
|
File vendorSource = file('src/main/java/dev/caskeleton/adapter/outbound/persistence/postgresql')
|
|
inputs.dir(vendorSource)
|
|
doLast {
|
|
List<String> violations = []
|
|
vendorSource.eachFileRecurse { File source ->
|
|
if (!source.name.endsWith('.java')) {
|
|
return
|
|
}
|
|
String text = source.getText('UTF-8')
|
|
def concatenatedSql = text =~ /(?s)(createNativeQuery|queryForObject|update)\s*\([^;]*"\s*\+/
|
|
if (concatenatedSql.find()) {
|
|
violations << "${source}: concatenated SQL construction"
|
|
}
|
|
source.readLines().eachWithIndex { String line, int index ->
|
|
if (line.contains("set_config('") && !line.contains('?')) {
|
|
violations << "${source}:${index + 1}: set_config value is not parameterized"
|
|
}
|
|
}
|
|
}
|
|
if (!violations.isEmpty()) {
|
|
throw new GradleException(
|
|
"verifyJpaSqlConstructionSafety: ${violations.size()} violation(s):\n " +
|
|
violations.join('\n '))
|
|
}
|
|
logger.lifecycle(
|
|
'verifyJpaSqlConstructionSafety: OK — no concatenated SQL construction and all set_config values are parameterized.')
|
|
}
|
|
}
|
|
|
|
def verifyJpaSecurityFixtures = tasks.register('verifyJpaSecurityFixtures') {
|
|
group = 'verification'
|
|
description = 'Verifies the no-skip PostgreSQL security fixture covers runtime-role namespace denial.'
|
|
File fixture = file(
|
|
'src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/readiness/PostgreSqlSecurityBaselineIntegrationTest.java')
|
|
inputs.file(fixture)
|
|
doLast {
|
|
if (!fixture.isFile()) {
|
|
throw new GradleException("verifyJpaSecurityFixtures: missing ${fixture}")
|
|
}
|
|
String text = fixture.getText('UTF-8')
|
|
['runtimeRoleCannotCreateInApplicationSchema', 'assertDockerAvailable', '42501'].each {
|
|
String required ->
|
|
if (!text.contains(required)) {
|
|
throw new GradleException(
|
|
"verifyJpaSecurityFixtures: ${fixture} is missing '${required}'")
|
|
}
|
|
}
|
|
logger.lifecycle(
|
|
'verifyJpaSecurityFixtures: OK — no-skip Docker and runtime-role namespace denial fixtures are present.')
|
|
}
|
|
}
|
|
|
|
postgresqlSecurityBaselineIntegrationTest.configure {
|
|
dependsOn verifyJpaSqlConstructionSafety
|
|
dependsOn verifyJpaSecurityFixtures
|
|
dependsOn project(':adapter:inbound:web').tasks.named('jpaPersistenceRedactionContractTest')
|
|
}
|
|
|
|
apply from: rootProject.file('gradle/jpa-evidence.gradle')
|