// 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 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')