feat: Tech Log Studio 백엔드 — 남은 17개 operation 구현 (슬라이스 2~5)

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>
This commit is contained in:
DongHyeonka
2026-08-19 23:21:43 +09:00
co-authored by Claude Opus 5
parent e615c24152
commit 48ff648112
163 changed files with 11807 additions and 10 deletions
@@ -26,6 +26,12 @@ dependencies {
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'
@@ -117,6 +123,14 @@ def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTe
'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.'
@@ -152,7 +152,7 @@ org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,postgresqlInt
org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -163,7 +163,7 @@ org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,postgresqlIntegr
org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
@@ -205,7 +205,7 @@ org.testcontainers:testcontainers:2.0.2=postgresqlIntegrationTestCompileClasspat
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,91 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PreviewArtifactPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code studio_preview} 접근.
*
* <p>{@code render_model}은 렌더러가 만든 계약 모양 그대로를 문자열로 저장하고 그대로 돌려준다 — 중간에서 파싱했다 다시 직렬화하면 사용자가 확인한 화면과
* 저장된 화면이 미묘하게 달라질 수 있고, 게시 시점에 그대로 snapshot 으로 옮겨야 하는 값이라 그 차이가 공개 결과까지 간다.
*/
@Repository
public class JdbcPreviewArtifactAdapter implements PreviewArtifactPort {
private final JdbcClient jdbcClient;
public JdbcPreviewArtifactAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicPreviewView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY created_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public Optional<PublicPreviewView> findById(UUID previewId) {
return jdbcClient
.sql(selectColumns() + " WHERE preview_id = :id")
.param("id", previewId)
.query(JdbcPreviewArtifactAdapter::mapRow)
.optional();
}
@Override
public PublicPreviewView save(RecordKind kind, PublicPreviewView preview, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_preview (preview_id, source_kind, source_id, source_version,"
+ " validation_id, dependency_revision, render_model, created_at, expires_at,"
+ " created_by)"
+ " VALUES (:previewId, :kind, :sourceId, :sourceVersion, :validationId,"
+ " :dependencyRevision, CAST(:renderModel AS jsonb), :createdAt, :expiresAt,"
+ " :principal)")
.param("previewId", preview.previewId())
.param("kind", kind.name())
.param("sourceId", preview.documentId())
.param("sourceVersion", preview.previewVersion())
.param("validationId", preview.validationId())
.param("dependencyRevision", preview.dependencyRevision())
.param("renderModel", preview.renderModelJson())
.param("createdAt", Timestamp.from(preview.createdAt()))
.param("expiresAt", Timestamp.from(preview.expiresAt()))
.param("principal", principal)
.update();
return preview;
}
private static String selectColumns() {
return "SELECT preview_id, source_id, source_version, validation_id, dependency_revision,"
+ " render_model, created_at, expires_at FROM studio_preview";
}
private static PublicPreviewView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new PublicPreviewView(
rs.getObject("preview_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("source_version"),
rs.getObject("validation_id", UUID.class),
rs.getString("dependency_revision"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("expires_at").toInstant(),
rs.getString("render_model"));
}
}
@@ -0,0 +1,143 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.artifact;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ValidationIssueView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationSeverity;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.port.out.ValidationArtifactPort;
import dev.caskeleton.shared.error.MappingException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* {@code studio_validation} 접근. 검증 결과는 일급 artifact이며 실행 후 버리지 않는다(spec §7.3).
*
* <p>{@code studio_validation}에는 UPDATE가 없다. 재검증은 새 행이며, 이전 결과는 "그때 이 버전은 이런 상태였다"는 사실로 남는다 — 덮어쓰면
* 게시 시점에 어떤 근거로 통과했는지 되짚을 수 없다.
*/
@Repository
public class JdbcValidationArtifactAdapter implements ValidationArtifactPort {
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
public JdbcValidationArtifactAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
}
@Override
public Optional<ValidationReportView> latestFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
selectColumns()
+ " WHERE source_kind = :kind AND source_id = :id"
+ " ORDER BY validated_at DESC LIMIT 1")
.param("kind", kind.name())
.param("id", documentId)
.query(this::mapRow)
.optional();
}
@Override
public Optional<ValidationReportView> findById(UUID validationId) {
return jdbcClient
.sql(selectColumns() + " WHERE validation_id = :id")
.param("id", validationId)
.query(this::mapRow)
.optional();
}
@Override
public ValidationReportView save(RecordKind kind, ValidationReportView report, String principal) {
jdbcClient
.sql(
"INSERT INTO studio_validation (validation_id, source_kind, source_id,"
+ " validated_version, status, issues, dependency_revision, validated_at,"
+ " valid_until, created_by)"
+ " VALUES (:validationId, :kind, :sourceId, :validatedVersion, :status,"
+ " CAST(:issues AS jsonb), :dependencyRevision, :validatedAt, :validUntil,"
+ " :principal)")
.param("validationId", report.validationId())
.param("kind", kind.name())
.param("sourceId", report.documentId())
.param("validatedVersion", report.validatedVersion())
.param("status", report.status().name())
.param("issues", issuesToJson(report.issues()))
.param("dependencyRevision", report.dependencyRevision())
.param("validatedAt", Timestamp.from(report.validatedAt()))
.param("validUntil", Timestamp.from(report.validUntil()))
.param("principal", principal)
.update();
return report;
}
private static String selectColumns() {
return "SELECT validation_id, source_id, validated_version, status, issues,"
+ " dependency_revision, validated_at, valid_until FROM studio_validation";
}
private ValidationReportView mapRow(ResultSet rs, int rowNum) throws SQLException {
return new ValidationReportView(
rs.getObject("validation_id", UUID.class),
rs.getObject("source_id", UUID.class),
rs.getLong("validated_version"),
ValidationStatus.valueOf(rs.getString("status")),
issuesFromJson(rs.getString("issues")),
rs.getTimestamp("validated_at").toInstant(),
rs.getTimestamp("valid_until").toInstant(),
rs.getString("dependency_revision"));
}
private String issuesToJson(List<ValidationIssueView> issues) {
ArrayNode array = objectMapper.createArrayNode();
for (ValidationIssueView issue : issues) {
ObjectNode node = array.addObject();
node.put("code", issue.code());
node.put("severity", issue.severity().name());
node.put("path", issue.path());
node.put("message", issue.message());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise validation issues", e);
}
}
private List<ValidationIssueView> issuesFromJson(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode array = objectMapper.readTree(json);
if (!array.isArray()) {
return List.of();
}
return array
.valueStream()
.map(
node ->
new ValidationIssueView(
node.path("code").asString(""),
ValidationSeverity.valueOf(node.path("severity").asString("ERROR")),
node.path("path").asString(""),
node.path("message").asString("")))
.toList();
} catch (JacksonException e) {
throw new MappingException("failed to read validation issues", e);
}
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.DependencyRevisionPort;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 의존 상태 해시를 {@link StudioDocumentSql}의 정의로 계산한다.
*
* <p>목록이 쓰는 SQL과 <b>같은 식</b>을 쓴다. 여기서 다른 식을 쓰면 상세 화면이 계산한 값과 목록이 계산한 값이 달라져, 검증을 막 통과한 문서가 목록에서는
* "다시 검증하라"로 보인다.
*/
@Repository
public class JdbcDependencyRevisionAdapter implements DependencyRevisionPort {
private final JdbcClient jdbcClient;
public JdbcDependencyRevisionAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public String revisionFor(RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT dependency_revision FROM studio_document WHERE id = :id")
.param("id", documentId)
.query(String.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"cannot compute a dependency revision for unknown document " + documentId));
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationQueryPort;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 현재 게시 상태 조회. {@code publication}은 (source_kind, source_id)당 한 행이다. */
@Repository
public class JdbcPublicationQueryAdapter implements PublicationQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Optional<PublicationAggregateView> currentFor(UUID documentId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at"
+ " FROM publication WHERE source_id = :id")
.param("id", documentId)
.query(
(rs, rowNum) ->
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("published_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("updated_at").toInstant()))
.optional();
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DashboardTotalsView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.port.out.StudioDashboardQueryPort;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 대시보드 집계. 목록과 <b>같은</b> {@code studio_document} 정의를 쓴다 — 대시보드가 "게시 준비됨"이라고 센 문서와 목록에서 그 필터로 나오는
* 문서가 달라지면 숫자를 믿을 수 없다.
*/
@Repository
public class JdbcStudioDashboardQueryAdapter implements StudioDashboardQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDashboardQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public List<DocumentSummaryView> topByNextAction(List<NextAction> actions, int limit) {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE next_action IN (:actions)"
+ " ORDER BY updated_at DESC, id DESC LIMIT :limit")
.param("actions", actions.stream().map(Enum::name).toList())
.param("limit", limit)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list();
}
@Override
public DashboardTotalsView totals() {
return jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT count(*) AS documents,"
+ " count(*) FILTER (WHERE next_action IN ('VALIDATE', 'FIX_VALIDATION'))"
+ " AS needs_validation,"
+ " count(*) FILTER (WHERE next_action = 'PUBLISH') AS ready_to_publish,"
+ " (SELECT count(*) FROM publication WHERE status = 'PUBLISHED') AS publications"
+ " FROM studio_document")
.query(
(rs, rowNum) ->
new DashboardTotalsView(
rs.getInt("documents"),
rs.getInt("needs_validation"),
rs.getInt("ready_to_publish"),
rs.getInt("publications")))
.single();
}
}
@@ -0,0 +1,225 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.PublicPaths;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.ResolvedAssetView;
import dev.caskeleton.application.techlog.studio.model.ResolvedRelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/** 편집본이 의존하는 바깥 상태를 한 번에 읽는다. 검증과 렌더링이 같은 결과를 공유하도록 조회는 이 한 곳에서만 한다. */
@Repository
public class JdbcStudioDependencyResolverAdapter implements StudioDependencyResolverPort {
private final JdbcClient jdbcClient;
public JdbcStudioDependencyResolverAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public Resolved resolve(WorkingCopyView document, Set<String> referencedAssetKeys) {
UUID topicId = document.base().topicId();
UUID projectId = document.base().projectId();
DisplayTargetView topic = topicId == null ? null : findTopic(topicId);
DisplayTargetView project = projectId == null ? null : findProject(projectId);
List<ResolvedRelationView> relations = new ArrayList<>();
List<UUID> missingTargets = new ArrayList<>();
resolveRelations(document, relations, missingTargets);
Map<String, ResolvedAssetView> assetsByKey = new LinkedHashMap<>();
Map<String, String> assetStatusByKey = new LinkedHashMap<>();
resolveAssets(referencedAssetKeys, assetsByKey, assetStatusByKey);
String projectSlug = projectId == null ? null : findProjectSlug(projectId);
String publicPath = PublicPaths.forKind(document.kind(), document.base().slug(), projectSlug);
return new Resolved(
topic,
topicId != null && topic == null,
project,
projectId != null && project == null,
relations,
missingTargets,
assetsByKey,
assetStatusByKey,
resolveEvidenceTarget(document),
publicPath,
findSlugOwner(document));
}
private DisplayTargetView findTopic(UUID topicId) {
return jdbcClient
.sql("SELECT id, name, slug FROM topic WHERE id = :id")
.param("id", topicId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
"/topics/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private DisplayTargetView findProject(UUID projectId) {
return jdbcClient
.sql("SELECT id, name, slug FROM project WHERE id = :id")
.param("id", projectId)
.query(
(rs, rowNum) ->
new DisplayTargetView(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("slug") == null ? null : "/projects/" + rs.getString("slug")))
.optional()
.orElse(null);
}
private String findProjectSlug(UUID projectId) {
return jdbcClient
.sql("SELECT slug FROM project WHERE id = :id")
.param("id", projectId)
.query(String.class)
.optional()
.orElse(null);
}
/**
* 관계 대상은 네 유형 어디에도 있을 수 있고 프로젝트일 수도 있다(계약 {@code ResolvedRelation.targetKind} 가 {@code
* RecordKind} 보다 하나 넓다). UNION 으로 한 번에 찾는다.
*/
private void resolveRelations(
WorkingCopyView document, List<ResolvedRelationView> resolved, List<UUID> missing) {
for (RelationView relation : document.base().relations()) {
if (relation.targetId() == null) {
continue;
}
TargetRow row = findTarget(relation.targetId());
if (row == null) {
missing.add(relation.targetId());
continue;
}
resolved.add(
new ResolvedRelationView(
relation.id(),
relation.targetId(),
row.kind(),
row.title(),
row.publicPath(),
relation.reason() == null ? "" : relation.reason(),
relation.order()));
}
}
private record TargetRow(String kind, String title, String publicPath) {}
private TargetRow findTarget(UUID targetId) {
return jdbcClient
.sql(
"SELECT document_type AS kind, title, slug, NULL::text AS project_slug FROM document"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'QUESTION', question, slug, NULL::text FROM open_question"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT', name, slug, NULL::text FROM project"
+ " WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT_DECISION', pd.title, pd.slug, p.slug"
+ " FROM project_decision pd LEFT JOIN project p ON p.id = pd.project_id"
+ " WHERE pd.id = :id")
.param("id", targetId)
.query(
(rs, rowNum) -> {
String kind = rs.getString("kind");
String slug = rs.getString("slug");
String publicPath =
"PROJECT".equals(kind)
? (slug == null ? null : "/projects/" + slug)
: PublicPaths.forKind(
RecordKind.valueOf(kind), slug, rs.getString("project_slug"));
return new TargetRow(kind, rs.getString("title"), publicPath);
})
.optional()
.orElse(null);
}
private void resolveAssets(
Set<String> keys, Map<String, ResolvedAssetView> assets, Map<String, String> statuses) {
if (keys.isEmpty()) {
return;
}
jdbcClient
.sql(
"SELECT id, asset_key, content_type, object_key, width, height, decorative,"
+ " management_status FROM asset WHERE asset_key IN (:keys)")
.param("keys", keys)
.query(
(rs, rowNum) -> {
String key = rs.getString("asset_key");
statuses.put(key, rs.getString("management_status"));
assets.put(
key,
new ResolvedAssetView(
rs.getObject("id", UUID.class),
key,
rs.getString("content_type"),
// 본문에는 object storage 경로가 아니라 안정적인 전송 경로를 싣는다
// (설계 05장 §3.1).
"/media/" + rs.getString("id"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getBoolean("decorative")));
return key;
})
.list();
}
private DisplayTargetView resolveEvidenceTarget(WorkingCopyView document) {
if (!(document instanceof WorkingCopyView.QuestionWorkingCopyView value)
|| value.resolution() == null
|| value.resolution().evidenceTargetId() == null) {
return null;
}
TargetRow row = findTarget(value.resolution().evidenceTargetId());
return row == null
? null
: new DisplayTargetView(
value.resolution().evidenceTargetId(), row.title(), row.publicPath());
}
/**
* 같은 공개 경로 이름공간(= 같은 유형)에서 이 slug 를 이미 쓰는 다른 기록. 유형이 다르면 경로 접두사가 달라 충돌하지 않는다({@code /cases/x} 와
* {@code /questions/x} 는 다른 주소다).
*/
private UUID findSlugOwner(WorkingCopyView document) {
String slug = document.base().slug();
if (slug == null || slug.isBlank()) {
return null;
}
String sql =
switch (document.kind()) {
case CASE, REFERENCE ->
"SELECT id FROM document WHERE slug = :slug AND document_type = :kind AND id <> :id";
case QUESTION -> "SELECT id FROM open_question WHERE slug = :slug AND id <> :id";
case PROJECT_DECISION ->
"SELECT id FROM project_decision WHERE slug = :slug AND id <> :id";
};
var spec = jdbcClient.sql(sql).param("slug", slug).param("id", document.id());
if (document.kind() == RecordKind.CASE || document.kind() == RecordKind.REFERENCE) {
spec = spec.param("kind", document.kind().name());
}
return spec.query(UUID.class).optional().orElse(null);
}
}
@@ -0,0 +1,141 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DocumentPageView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.port.out.StudioDocumentQueryPort;
import dev.caskeleton.application.techlog.studio.query.DocumentCursorPosition;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code listStudioDocuments}. 네 유형이 서로 다른 테이블에 살기 때문에 공통 repository 대신 union projection을 돌린다(계약
* 설명, spec §8.3).
*
* <p>정렬 키에 항상 {@code id}를 붙인다. {@code updated_at}만으로 자르면 같은 시각의 행들이 페이지 경계에서 중복되거나 누락된다 — 대량 저장 직후에
* 실제로 일어나는 일이다.
*/
@Repository
public class JdbcStudioDocumentQueryAdapter implements StudioDocumentQueryPort {
private final JdbcClient jdbcClient;
public JdbcStudioDocumentQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public DocumentPageView list(ListDocumentsQuery query) {
StringBuilder sql = new StringBuilder(StudioDocumentSql.documentProjectionCte());
sql.append(" SELECT * FROM studio_document WHERE 1 = 1");
Map<String, Object> params = new java.util.HashMap<>();
if (query.kind() != null) {
sql.append(" AND kind = :kind");
params.put("kind", query.kind().name());
}
if (query.projectId() != null) {
sql.append(" AND project_id = :projectId");
params.put("projectId", query.projectId());
}
if (query.nextAction() != null) {
sql.append(" AND next_action = :nextAction");
params.put("nextAction", query.nextAction().name());
}
if (query.publicationStatus() != null) {
sql.append(publicationStatusPredicate());
params.put("publicationStatus", query.publicationStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND lower(title) LIKE :titlePattern");
params.put("titlePattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
appendCursorPredicate(sql, params, query);
sql.append(orderBy(query.sort()));
// limit + 1 을 읽어 "다음 쪽이 있는가"를 별도 count 없이 판정한다.
sql.append(" LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
boolean hasMore = rows.size() > query.limit();
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
List<DocumentSummaryView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(row.summary());
}
return new DocumentPageView(
items, hasMore ? cursorPayload(page.getLast(), query.sort()) : null);
}
/**
* 게시 이력이 없는 문서는 {@code publication} 행 자체가 없다 — {@code NEVER_PUBLISHED}는 "행이 없음"이지 특정 status 값이
* 아니다.
*/
private static String publicationStatusPredicate() {
return " AND ((:publicationStatus = 'NEVER_PUBLISHED' AND publication_status IS NULL)"
+ " OR publication_status = :publicationStatus)";
}
private static String orderBy(DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC -> " ORDER BY updated_at DESC, id DESC";
case UPDATED_ASC -> " ORDER BY updated_at ASC, id ASC";
case TITLE_ASC -> " ORDER BY title ASC, id ASC";
};
}
private static void appendCursorPredicate(
StringBuilder sql, Map<String, Object> params, ListDocumentsQuery query) {
DocumentCursorPosition position = query.position();
if (position == null) {
return;
}
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 정렬이 늘면 컴파일러가
// 여기서 막아 준다(문이면 커서 조건 없이 조용히 첫 페이지를 다시 준다).
String predicate =
switch (query.sort()) {
case UPDATED_DESC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) < (:cursorUpdatedAt, :cursorId)";
}
case UPDATED_ASC -> {
params.put("cursorUpdatedAt", Timestamp.from(position.updatedAt()));
yield " AND (updated_at, id) > (:cursorUpdatedAt, :cursorId)";
}
case TITLE_ASC -> {
params.put("cursorTitle", position.title());
yield " AND (title, id) > (:cursorTitle, :cursorId)";
}
};
params.put("cursorId", position.id());
sql.append(predicate);
}
/** 다음 쪽의 시작 위치. web 계층이 이 값을 서명해 opaque cursor 로 만든다. */
private static String cursorPayload(Row last, DocumentSort sort) {
return switch (sort) {
case UPDATED_DESC, UPDATED_ASC -> last.updatedAt().toInstant() + "|" + last.summary().id();
case TITLE_ASC -> last.title() + "|" + last.summary().id();
};
}
/** 커서 계산에 필요한 정렬 키만 요약과 함께 들고 다닌다. */
private record Row(DocumentSummaryView summary, java.sql.Timestamp updatedAt, String title) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new Row(
StudioDocumentRowMapper.read(rs), rs.getTimestamp("updated_at"), rs.getString("title"));
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.model.DisplayTargetView;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.PublicationStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/**
* {@code studio_document} 한 행 → 계약 {@code DocumentSummary}.
*
* <p>목록·대시보드·게시 이력이 모두 이 매퍼를 쓴다. 화면마다 따로 만들면 같은 문서가 화면마다 다른 {@code nextAction} 이나 {@code
* hasUnpublishedChanges} 로 보인다.
*/
public final class StudioDocumentRowMapper {
private StudioDocumentRowMapper() {}
public static DocumentSummaryView read(ResultSet rs) throws SQLException {
UUID projectId = rs.getObject("project_id", UUID.class);
String projectSlug = rs.getString("project_slug");
Long publishedVersion = (Long) rs.getObject("published_version");
long version = rs.getLong("version");
String publicationStatus = rs.getString("publication_status");
return new DocumentSummaryView(
rs.getObject("id", UUID.class),
rs.getString("title"),
RecordKind.valueOf(rs.getString("kind")),
projectId == null
? null
: new DisplayTargetView(
projectId,
rs.getString("project_name"),
projectSlug == null ? null : "/projects/" + projectSlug),
rs.getTimestamp("updated_at").toInstant(),
publicationStatus == null
? PublicationStatusView.NEVER_PUBLISHED
: PublicationStatusView.valueOf(publicationStatus),
publishedVersion,
// 계약: "게시 취소 상태에서도 과거 publishedVersion 과 비교한다."
publishedVersion != null && publishedVersion != version,
NextAction.valueOf(rs.getString("next_action")));
}
}
@@ -0,0 +1,130 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
/**
* Studio 문서 union projection의 SQL 정의. 목록·대시보드·단건 조회가 <b>같은</b> 정의를 쓴다.
*
* <p>{@code dependencyRevision}과 {@code nextAction}은 저장하지 않고 계산하는 값이다(spec §6.3, §7.3). 계산식이 SQL 한
* 곳과 Java 한 곳에 따로 있으면 목록의 {@code nextAction}과 상세의 {@code nextAction}이 조용히 갈라진다 — 사용자에게는 "목록에서는
* 게시하라더니 열어 보니 검증하라"는 모순으로 보인다. 그래서 계산은 여기 SQL 한 벌만 둔다.
*/
public final class StudioDocumentSql {
/**
* 렌더 계약 버전. 렌더 결과의 의미가 바뀌면 올린다 — 올리는 순간 기존 validation/preview가 전부 stale이 되어 다시 검증·미리보기를 거치게 된다.
*/
public static final String RENDERER_CONTRACT_VERSION = "1";
private StudioDocumentSql() {}
/**
* 네 유형을 하나의 행 모양으로 모으고, 그 위에 의존 상태 해시와 최신 artifact를 붙인 CTE 묶음.
*
* <p>마지막 CTE {@code studio_document}가 최종 결과이며 컬럼은 다음과 같다.
*
* <pre>{@code
* id kind title version updated_at topic_id project_id project_name project_slug
* dependency_revision
* validation_version validation_status validation_valid_until validation_revision
* preview_version preview_expires_at preview_revision
* publication_status published_version
* next_action
* }</pre>
*/
public static String documentProjectionCte() {
return """
WITH studio_source AS (
SELECT d.id,
d.document_type AS kind,
d.title,
d.version,
d.updated_at,
d.primary_topic_id AS topic_id,
(SELECT l.project_id FROM project_document_link l
WHERE l.document_id = d.id AND l.relation_type = 'PRIMARY') AS project_id
FROM document d
UNION ALL
SELECT q.id, 'QUESTION', q.question, q.version, q.updated_at, q.primary_topic_id,
(SELECT l.project_id FROM project_question_link l
WHERE l.question_id = q.id AND l.relation_type = 'PRIMARY')
FROM open_question q
UNION ALL
SELECT pd.id, 'PROJECT_DECISION', pd.title, pd.version, pd.updated_at,
pd.primary_topic_id, pd.project_id
FROM project_decision pd
),
studio_dependency AS (
SELECT s.id,
md5(concat_ws('|',
coalesce(t.id::text || ':' || t.version::text, '-'),
coalesce(p.id::text || ':' || p.version::text, '-'),
coalesce((
SELECT md5(string_agg(rel.sig, ',' ORDER BY rel.sig))
FROM (
SELECT r.target_id::text || ':'
|| coalesce(rd.version, rq.version, rp.version, 0)::text AS sig
FROM studio_relation r
LEFT JOIN document rd ON rd.id = r.target_id
LEFT JOIN open_question rq ON rq.id = r.target_id
LEFT JOIN project_decision rp ON rp.id = r.target_id
WHERE r.source_kind = s.kind AND r.source_id = s.id
) rel
), '-'),
'renderer:%s'
)) AS dependency_revision
FROM studio_source s
LEFT JOIN topic t ON t.id = s.topic_id
LEFT JOIN project p ON p.id = s.project_id
),
studio_latest_validation AS (
SELECT DISTINCT ON (v.source_id)
v.source_id, v.validation_id, v.validated_version, v.status,
v.valid_until, v.dependency_revision
FROM studio_validation v
ORDER BY v.source_id, v.validated_at DESC
),
studio_latest_preview AS (
SELECT DISTINCT ON (pv.source_id)
pv.source_id, pv.preview_id, pv.source_version, pv.expires_at,
pv.dependency_revision
FROM studio_preview pv
ORDER BY pv.source_id, pv.created_at DESC
),
studio_document AS (
SELECT s.id, s.kind, s.title, s.version, s.updated_at, s.topic_id, s.project_id,
pr.name AS project_name, pr.slug AS project_slug,
dep.dependency_revision,
val.validated_version AS validation_version,
val.status AS validation_status,
val.valid_until AS validation_valid_until,
val.dependency_revision AS validation_revision,
prev.source_version AS preview_version,
prev.expires_at AS preview_expires_at,
prev.dependency_revision AS preview_revision,
pub.status AS publication_status,
pub.published_version,
CASE
WHEN s.title IS NULL OR btrim(s.title) = '' THEN 'CONTINUE_EDITING'
WHEN val.validated_version IS NULL
OR val.validated_version <> s.version
OR val.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR val.valid_until <= now() THEN 'VALIDATE'
WHEN val.status = 'INVALID' THEN 'FIX_VALIDATION'
WHEN prev.source_version IS NULL
OR prev.source_version <> s.version
OR prev.dependency_revision IS DISTINCT FROM dep.dependency_revision
OR prev.expires_at <= now() THEN 'CREATE_PREVIEW'
WHEN pub.status IS DISTINCT FROM 'PUBLISHED'
OR pub.published_version <> s.version THEN 'PUBLISH'
ELSE 'NONE'
END AS next_action
FROM studio_source s
JOIN studio_dependency dep ON dep.id = s.id
LEFT JOIN project pr ON pr.id = s.project_id
LEFT JOIN studio_latest_validation val ON val.source_id = s.id
LEFT JOIN studio_latest_preview prev ON prev.source_id = s.id
LEFT JOIN publication pub ON pub.source_id = s.id AND pub.source_kind = s.kind
)
"""
.formatted(RENDERER_CONTRACT_VERSION);
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset;
import dev.caskeleton.application.techlog.studio.model.AssetDetailView;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetPageView;
import dev.caskeleton.application.techlog.studio.model.AssetUsageView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* {@code asset} 메타데이터 접근.
*
* <p>{@code usageCount} 는 {@code asset_reference} 에서 센다 — Asset 행에 캐시해 두면 참조가 바뀔 때마다 두 곳을 맞춰야 하고,
* 어긋나면 "쓰이고 있는데 삭제 가능"으로 보인다.
*/
@Repository
public class JdbcAssetRepositoryAdapter implements AssetRepositoryPort {
private final JdbcClient jdbcClient;
public JdbcAssetRepositoryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public AssetPageView list(ListAssetsQuery query) {
StringBuilder sql = new StringBuilder(selectColumns() + " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.kind() != null) {
sql.append(" AND a.asset_kind = :kind");
params.put("kind", query.kind().name());
}
if (query.managementStatus() != null) {
sql.append(" AND a.management_status = :status");
params.put("status", query.managementStatus().name());
}
if (query.query() != null && !query.query().isBlank()) {
sql.append(" AND (lower(a.asset_key) LIKE :pattern OR lower(a.original_name) LIKE :pattern)");
params.put("pattern", "%" + query.query().toLowerCase(Locale.ROOT) + "%");
}
if (query.beforeCreatedAt() != null && query.beforeId() != null) {
sql.append(" AND (a.created_at, a.id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeCreatedAt()));
params.put("beforeId", query.beforeId());
}
sql.append(" ORDER BY a.created_at DESC, a.id DESC LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<AssetView> rows = spec.query(JdbcAssetRepositoryAdapter::mapAsset).list();
boolean hasMore = rows.size() > query.limit();
List<AssetView> page = hasMore ? rows.subList(0, query.limit()) : rows;
String nextCursor = hasMore ? page.getLast().createdAt() + "|" + page.getLast().id() : null;
return new AssetPageView(page, nextCursor);
}
@Override
public Optional<AssetView> find(UUID assetId) {
return jdbcClient
.sql(selectColumns() + " WHERE a.id = :id")
.param("id", assetId)
.query(JdbcAssetRepositoryAdapter::mapAsset)
.optional();
}
@Override
public Optional<AssetDetailView> findDetail(UUID assetId) {
return find(assetId)
.map(
asset -> new AssetDetailView(asset, usagesOf(assetId), hasPublicationHistory(assetId)));
}
@Override
public AssetView create(NewAsset asset, String principal) {
jdbcClient
.sql(
"INSERT INTO asset (id, asset_key, asset_kind, management_status, object_key,"
+ " original_name, display_name, content_type, size_bytes, width, height,"
+ " checksum_sha256, alt_text, decorative, version, created_by, updated_by)"
+ " VALUES (:id, :assetKey, :kind, :status, :objectKey, :originalName,"
+ " :originalName, :contentType, :size, :width, :height, :checksum, :altText,"
// 계약의 Asset.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면 생성 직후
// 응답이 계약을 위반하고, 클라이언트가 보내는 expectedVersion 도 맞출 수 없다.
+ " :decorative, 1, :principal, :principal)")
.param("id", asset.id())
.param("assetKey", asset.assetKey())
.param("kind", asset.kind().name())
.param("status", asset.managementStatus().name())
.param("objectKey", asset.objectKey())
.param("originalName", asset.originalFilename())
.param("contentType", asset.mediaType())
.param("size", asset.byteSize())
.param("width", asset.width())
.param("height", asset.height())
.param("checksum", asset.checksumSha256())
.param("altText", asset.altText())
.param("decorative", asset.decorative())
.param("principal", principal)
.update();
return find(asset.id()).orElseThrow();
}
@Override
public Optional<AssetView> update(
UUID assetId,
long expectedVersion,
AssetKindView kind,
String altText,
boolean altTextProvided,
Boolean decorative,
AssetManagementStatusView managementStatus,
String principal) {
int updated =
jdbcClient
.sql(
"UPDATE asset SET"
// 보내지 않은 필드는 그대로 둔다 — PUT 이지만 계약의 UpdateAssetCommand 는
// expectedVersion 외 전부 optional 이라 부분 갱신 의미다.
+ " asset_kind = COALESCE(:kind, asset_kind),"
+ " alt_text = CASE WHEN :altTextProvided THEN :altText ELSE alt_text END,"
+ " decorative = COALESCE(:decorative, decorative),"
+ " management_status = COALESCE(:status, management_status),"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("kind", kind == null ? null : kind.name())
.param("altTextProvided", altTextProvided)
.param("altText", altText)
.param("decorative", decorative)
.param("status", managementStatus == null ? null : managementStatus.name())
.param("principal", principal)
.param("id", assetId)
.param("expectedVersion", expectedVersion)
.update();
return updated == 0 ? Optional.empty() : find(assetId);
}
@Override
public Optional<String> findObjectKey(UUID assetId) {
return jdbcClient
.sql("SELECT object_key FROM asset WHERE id = :id")
.param("id", assetId)
.query(String.class)
.optional();
}
@Override
public void delete(UUID assetId) {
jdbcClient.sql("DELETE FROM asset WHERE id = :id").param("id", assetId).update();
}
private static String selectColumns() {
return "SELECT a.id, a.asset_key, a.asset_kind, a.management_status, a.original_name,"
+ " a.content_type, a.size_bytes, a.width, a.height, a.alt_text, a.decorative,"
+ " a.version, a.created_at, a.updated_at, a.first_published_at,"
+ " (SELECT count(*) FROM asset_reference r WHERE r.asset_id = a.id) AS usage_count"
+ " FROM asset a";
}
private static AssetView mapAsset(ResultSet rs, int rowNum) throws SQLException {
return new AssetView(
rs.getObject("id", UUID.class),
rs.getString("asset_key"),
AssetKindView.valueOf(rs.getString("asset_kind")),
rs.getString("content_type"),
rs.getString("original_name"),
rs.getLong("size_bytes"),
(Integer) rs.getObject("width"),
(Integer) rs.getObject("height"),
rs.getString("alt_text"),
rs.getBoolean("decorative"),
AssetManagementStatusView.valueOf(rs.getString("management_status")),
// 본문에 저장소 경로를 싣지 않는다(설계 05장 §3.1). 안정적인 전송 경로만 노출한다.
"/media/" + rs.getString("id"),
rs.getInt("usage_count"),
rs.getLong("version"),
rs.getTimestamp("created_at").toInstant(),
rs.getTimestamp("updated_at").toInstant());
}
private List<AssetUsageView> usagesOf(UUID assetId) {
return jdbcClient
.sql(
"SELECT r.owner_id, r.owner_type, r.reference_scope,"
+ " COALESCE(d.title, q.question, pd.title) AS title,"
+ " COALESCE(d.document_type, 'QUESTION') AS document_kind"
+ " FROM asset_reference r"
+ " LEFT JOIN document d ON d.id = r.owner_id"
+ " LEFT JOIN open_question q ON q.id = r.owner_id"
+ " LEFT JOIN project_decision pd ON pd.id = r.owner_id"
+ " WHERE r.asset_id = :id")
.param("id", assetId)
.query(
(rs, rowNum) ->
new AssetUsageView(
rs.getObject("owner_id", UUID.class),
kindOf(rs.getString("owner_type"), rs.getString("document_kind")),
rs.getString("title") == null ? "(제목 없음)" : rs.getString("title"),
"PUBLISHED".equals(rs.getString("reference_scope"))))
.list();
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static RecordKind kindOf(String ownerType, String documentKind) {
return switch (ownerType) {
case "DOCUMENT" -> RecordKind.valueOf(documentKind);
case "DECISION" -> RecordKind.PROJECT_DECISION;
default -> RecordKind.QUESTION;
};
}
/** 한 번이라도 공개된 적이 있으면 hard delete 를 금지한다(계약 {@code AssetDetail} 설명). */
private boolean hasPublicationHistory(UUID assetId) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT (first_published_at IS NOT NULL"
+ " OR EXISTS (SELECT 1 FROM asset_reference r"
+ " WHERE r.asset_id = :id AND r.reference_scope = 'PUBLISHED'))"
+ " FROM asset WHERE id = :id")
.param("id", assetId)
.query(Boolean.class)
.optional()
.orElse(Boolean.FALSE));
}
}
@@ -0,0 +1,192 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentRowMapper;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.StudioDocumentSql;
import dev.caskeleton.application.techlog.studio.model.DocumentSummaryView;
import dev.caskeleton.application.techlog.studio.model.PublicationActionView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublicationListItemView;
import dev.caskeleton.application.techlog.studio.model.PublicationPageView;
import dev.caskeleton.application.techlog.studio.model.PublicationSnapshotView;
import dev.caskeleton.application.techlog.studio.port.out.PublicationHistoryQueryPort;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* 게시 이력 조회. 이력은 항상 최신순이며 정렬 선택지가 없다 — 계약에도 정렬 파라미터가 없다.
*
* <p>목록의 {@code document} 요약은 {@link StudioDocumentSql} 의 같은 정의에서 가져온다. 여기서 따로 만들면 목록 화면과 이력 화면의
* {@code nextAction} 이 갈라진다.
*/
@Repository
public class JdbcPublicationHistoryQueryAdapter implements PublicationHistoryQueryPort {
private final JdbcClient jdbcClient;
public JdbcPublicationHistoryQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public PublicationPageView list(ListPublicationsQuery query) {
StringBuilder sql =
new StringBuilder(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available,"
+ " p.status, p.publication_revision, p.latest_event_id, p.public_path,"
+ " p.updated_at AS publication_updated_at, p.published_version AS current_version"
+ " FROM publication_event e"
+ " JOIN publication p ON p.publication_id = e.publication_id"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE 1 = 1");
Map<String, Object> params = new HashMap<>();
if (query.type() != null) {
sql.append(" AND e.event_type = :type");
params.put("type", query.type().name());
}
if (query.beforeOccurredAt() != null && query.beforeEventId() != null) {
sql.append(" AND (e.occurred_at, e.publication_event_id) < (:before, :beforeId)");
params.put("before", Timestamp.from(query.beforeOccurredAt()));
params.put("beforeId", query.beforeEventId());
}
sql.append(" ORDER BY e.occurred_at DESC, e.publication_event_id DESC LIMIT :limitPlusOne");
params.put("limitPlusOne", query.limit() + 1);
var spec = jdbcClient.sql(sql.toString());
for (Map.Entry<String, Object> param : params.entrySet()) {
spec = spec.param(param.getKey(), param.getValue());
}
List<Row> rows = spec.query((rs, rowNum) -> readRow(rs)).list();
boolean hasMore = rows.size() > query.limit();
List<Row> page = hasMore ? rows.subList(0, query.limit()) : rows;
Map<UUID, DocumentSummaryView> summaries = summariesFor(page);
List<PublicationListItemView> items = new ArrayList<>(page.size());
for (Row row : page) {
items.add(
new PublicationListItemView(
row.event(),
row.publication(),
summaries.get(row.event().documentId()),
actionsFor(row)));
}
String nextCursor =
hasMore
? page.getLast().event().occurredAt()
+ "|"
+ page.getLast().event().publicationEventId()
: null;
return new PublicationPageView(items, nextCursor);
}
@Override
public Optional<PublicationSnapshotView> findSnapshot(UUID publicationEventId) {
return jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " true AS snapshot_available,"
+ " s.render_model, s.content_format_version, s.renderer_contract_version"
+ " FROM publication_snapshot s"
+ " JOIN publication_event e"
+ " ON e.publication_event_id = s.publication_event_id"
+ " WHERE s.publication_event_id = :id")
.param("id", publicationEventId)
.query(
(rs, rowNum) ->
new PublicationSnapshotView(
PublicationRowMappers.mapEvent(rs, rowNum),
rs.getString("render_model"),
rs.getString("content_format_version"),
rs.getString("renderer_contract_version")))
.optional();
}
@Override
public Optional<PublicationAggregateView> findById(UUID publicationId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at FROM publication"
+ " WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
private Map<UUID, DocumentSummaryView> summariesFor(List<Row> rows) {
if (rows.isEmpty()) {
return Map.of();
}
List<UUID> ids = rows.stream().map(row -> row.event().documentId()).distinct().toList();
Map<UUID, DocumentSummaryView> summaries = new HashMap<>();
jdbcClient
.sql(
StudioDocumentSql.documentProjectionCte()
+ " SELECT * FROM studio_document WHERE id IN (:ids)")
.param("ids", ids)
.query((rs, rowNum) -> StudioDocumentRowMapper.read(rs))
.list()
.forEach(summary -> summaries.put(summary.id(), summary));
return summaries;
}
/**
* 계약 {@code PublicationListItem.availableActions}. 지금 상태에서 실제로 할 수 있는 것만 담는다 — 화면이 눌러도 실패할 버튼을
* 그리지 않게 하려는 값이다.
*/
private static List<PublicationActionView> actionsFor(Row row) {
List<PublicationActionView> actions = new ArrayList<>();
if (row.event().snapshotAvailable()) {
actions.add(PublicationActionView.VIEW_SNAPSHOT);
}
if (row.event().sourcePublishedEventId() != null) {
actions.add(PublicationActionView.VIEW_SOURCE_SNAPSHOT);
}
if (row.publication().status() == PublicationAggregateStatus.PUBLISHED
&& row.publication().latestEventId().equals(row.event().publicationEventId())) {
actions.add(PublicationActionView.UNPUBLISH);
}
return actions;
}
private record Row(PublicationEventView event, PublicationAggregateView publication) {}
private static Row readRow(java.sql.ResultSet rs) throws java.sql.SQLException {
PublicationEventView event =
new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
PublicationAggregateView publication =
new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("current_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("publication_updated_at").toInstant());
return new Row(event, publication);
}
}
@@ -0,0 +1,514 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.AssetManifestEntry;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.shared.error.MappingException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* spec §7.5 의 게시 트랜잭션 10~19단계.
*
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 는 서로를 가리킨다. 첫
* 게시는 publication INSERT → event INSERT → publication UPDATE 순서로 한 트랜잭션 안에서 끝나며, 그 순환을 허용하는 것이 V7 의
* {@code DEFERRABLE INITIALLY DEFERRED} 다. 즉시 검사로 바꾸면 첫 게시가 구조적으로 불가능해진다.
*/
@Repository
public class JdbcPublicationWriterAdapter implements PublicationWriterPort {
/** 공개 projection payload 의 스키마 버전. 모양이 바뀌면 올린다. */
private static final short PAYLOAD_SCHEMA_VERSION = 1;
private final JdbcClient jdbcClient;
private final ObjectMapper objectMapper;
private final Supplier<UUID> idGenerator;
/**
* 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
* 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
*/
@Autowired
public JdbcPublicationWriterAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this(jdbcClient, objectMapper, UUID::randomUUID);
}
JdbcPublicationWriterAdapter(
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
this.idGenerator = idGenerator;
}
@Override
public Optional<PublicationAggregateView> lockCurrentPublication(
RecordKind kind, UUID documentId) {
return jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version, publication_revision,"
+ " latest_event_id, public_path, updated_at FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id FOR UPDATE")
.param("kind", kind.name())
.param("id", documentId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.optional();
}
@Override
public PublishResultView publish(PublishRequest request) {
requireTransaction("publish");
Optional<PublicationAggregateView> existing =
lockCurrentPublication(request.kind(), request.documentId());
UUID publicationId =
existing.map(PublicationAggregateView::publicationId).orElseGet(idGenerator);
UUID eventId = idGenerator.get();
PublicationEventTypeView eventType =
existing.isEmpty()
? PublicationEventTypeView.PUBLISHED
: PublicationEventTypeView.REPUBLISHED;
if (existing.isEmpty()) {
// 10 이전: aggregate 를 먼저 만든다. latest_event_id 는 아직 없는 event 를 가리키지만
// 지연 검사라 커밋 시점에만 확인된다.
jdbcClient
.sql(
"INSERT INTO publication (publication_id, source_kind, source_id, status,"
+ " published_version, publication_revision, latest_event_id, public_path)"
+ " VALUES (:publicationId, :kind, :id, 'PUBLISHED', :version, 1, :eventId,"
+ " :publicPath)")
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.update();
}
// 10. Event
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, occurred_at, idempotency_key,"
+ " created_by)"
+ " VALUES (:eventId, :publicationId, :kind, :id, :type, :version, now(),"
+ " :idempotencyKey, :principal)")
.param("eventId", eventId)
.param("publicationId", publicationId)
.param("kind", request.kind().name())
.param("id", request.documentId())
.param("type", eventType.name())
.param("version", request.version())
.param("idempotencyKey", request.idempotencyKey())
.param("principal", request.principal())
.update();
// 11. Snapshot — 게시 시점의 렌더 모델을 그대로 고정한다. 다시 렌더링하지 않는다.
jdbcClient
.sql(
"INSERT INTO publication_snapshot (publication_event_id, render_model,"
+ " content_format_version, renderer_contract_version, asset_manifest)"
+ " VALUES (:eventId, CAST(:renderModel AS jsonb), :contentFormatVersion,"
+ " :rendererContractVersion, CAST(:assetManifest AS jsonb))")
.param("eventId", eventId)
.param("renderModel", request.renderModelJson())
.param("contentFormatVersion", request.contentFormatVersion())
.param("rendererContractVersion", request.rendererContractVersion())
.param("assetManifest", manifestJson(request.assetManifest()))
.update();
upsertProjection(request);
replaceRoute(request);
replaceProjectLink(request);
replacePublishedAssetReferences(request);
markAssetsFirstPublished(request);
// 17. aggregate 갱신
jdbcClient
.sql(
"UPDATE publication SET status = 'PUBLISHED', published_version = :version,"
+ " publication_revision = publication_revision + :bump,"
+ " latest_event_id = :eventId, public_path = :publicPath, updated_at = now()"
+ " WHERE publication_id = :publicationId")
.param("version", request.version())
// 첫 게시는 INSERT 가 이미 revision 1 을 넣었다. 여기서 또 올리면 클라이언트가 받은 값과
// 다음 unpublish 가 요구하는 값이 어긋난다.
.param("bump", existing.isEmpty() ? 0 : 1)
.param("eventId", eventId)
.param("publicPath", request.publicPath())
.param("publicationId", publicationId)
.update();
// 18. Document publish metadata
markSourcePublished(request);
return result(publicationId, eventId);
}
@Override
public PublishResultView unpublish(UnpublishRequest request) {
requireTransaction("unpublish");
UUID lastPublishedEventId =
jdbcClient
.sql(
"SELECT publication_event_id FROM publication_event"
+ " WHERE publication_id = :publicationId"
+ " AND event_type IN ('PUBLISHED', 'REPUBLISHED')"
+ " ORDER BY occurred_at DESC LIMIT 1")
.param("publicationId", request.publicationId())
.query(UUID.class)
.optional()
.orElseThrow(
() ->
StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"this publication has no published event to withdraw"));
UUID eventId = idGenerator.get();
// UNPUBLISHED Event 는 자체 snapshot 을 만들지 않고 마지막 공개 Snapshot 을 참조한다(V7 주석).
jdbcClient
.sql(
"INSERT INTO publication_event (publication_event_id, publication_id, source_kind,"
+ " source_id, event_type, published_version, source_published_event_id,"
+ " occurred_at, created_by)"
+ " SELECT :eventId, p.publication_id, p.source_kind, p.source_id, 'UNPUBLISHED',"
+ " p.published_version, :sourceEventId, now(), :principal"
+ " FROM publication p WHERE p.publication_id = :publicationId")
.param("eventId", eventId)
.param("sourceEventId", lastPublishedEventId)
.param("principal", request.principal())
.param("publicationId", request.publicationId())
.update();
int updated =
jdbcClient
.sql(
"UPDATE publication SET status = 'UNPUBLISHED',"
+ " publication_revision = publication_revision + 1,"
+ " latest_event_id = :eventId, updated_at = now()"
+ " WHERE publication_id = :publicationId"
+ " AND publication_revision = :expectedRevision")
.param("eventId", eventId)
.param("publicationId", request.publicationId())
.param("expectedRevision", request.expectedRevision())
.update();
if (updated == 0) {
throw StudioException.of(
StudioError.PUBLICATION_CONFLICT,
"the publication revision changed while withdrawing it");
}
// Projection ACTIVE -> WITHDRAWN. route 는 유지한다 — 주소가 사라지면 링크가 끊긴다.
jdbcClient
.sql(
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
+ " updated_at = now() WHERE resource_type = :type AND resource_id = :id")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
// Working copy 는 다시 초안으로 돌아간다.
if (request.kind() == RecordKind.CASE || request.kind() == RecordKind.REFERENCE) {
jdbcClient
.sql("UPDATE document SET workflow_status = 'DRAFT' WHERE id = :id")
.param("id", request.documentId())
.update();
}
return result(request.publicationId(), eventId);
}
/** 12. 공개 projection upsert. */
private void upsertProjection(PublishRequest request) {
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, state_code, primary_topic_id,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, navigation_path)"
+ " VALUES (:type, :id, :version, 'ACTIVE', 'PUBLIC', :title, :summary,"
+ " :stateCode, :topicId, :schemaVersion, CAST(:payload AS jsonb), :bodyPlainText,"
+ " :searchText, :contentHash, now(), now(), :navigationPath)"
+ " ON CONFLICT (resource_type, resource_id) DO UPDATE SET"
+ " source_version = EXCLUDED.source_version,"
+ " publication_state = 'ACTIVE',"
+ " visibility = EXCLUDED.visibility,"
+ " title = EXCLUDED.title,"
+ " summary = EXCLUDED.summary,"
+ " state_code = EXCLUDED.state_code,"
+ " primary_topic_id = EXCLUDED.primary_topic_id,"
+ " payload = EXCLUDED.payload,"
+ " body_plain_text = EXCLUDED.body_plain_text,"
+ " search_text = EXCLUDED.search_text,"
+ " content_hash = EXCLUDED.content_hash,"
+ " updated_at = now(),"
+ " navigation_path = EXCLUDED.navigation_path")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("version", request.version())
.param("title", request.title())
.param("summary", request.summary())
.param("stateCode", request.stateCode())
.param("topicId", request.topicId())
.param("schemaVersion", PAYLOAD_SCHEMA_VERSION)
.param("payload", request.renderModelJson())
.param("bodyPlainText", request.bodyPlainText())
.param(
"searchText",
String.join(
" ",
nullToEmpty(request.title()),
nullToEmpty(request.summary()),
nullToEmpty(request.bodyPlainText())))
.param("contentHash", sha256(request.renderModelJson()))
.param("navigationPath", request.publicPath())
.update();
}
/** 13. canonical route. 이전 slug 의 route 는 alias 로 남긴다 — 지우면 공개된 링크가 끊긴다. */
private void replaceRoute(PublishRequest request) {
jdbcClient
.sql(
"UPDATE public_route SET route_role = 'ALIAS'"
+ " WHERE resource_type = :type AND resource_id = :id AND slug <> :slug")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("slug", slugOf(request.publicPath()))
.update();
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES (:type, :slug, :id, 'CANONICAL')"
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
.param("type", request.kind().name())
.param("slug", slugOf(request.publicPath()))
.param("id", request.documentId())
.update();
}
/** 14. 공개 projection 의 프로젝트 링크. Studio 는 PRIMARY 하나만 소유한다. */
private void replaceProjectLink(PublishRequest request) {
jdbcClient
.sql(
"DELETE FROM public_resource_project_link"
+ " WHERE resource_type = :type AND resource_id = :id AND relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.update();
if (request.projectId() == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO public_resource_project_link (resource_type, resource_id, project_id,"
+ " relation_type) VALUES (:type, :id, :projectId, 'PRIMARY')"
+ " ON CONFLICT (resource_type, resource_id, project_id)"
+ " DO UPDATE SET relation_type = 'PRIMARY'")
.param("type", request.kind().name())
.param("id", request.documentId())
.param("projectId", request.projectId())
.update();
}
/** 15. PUBLISHED scope 의 asset_reference 교체. WORKING scope 는 건드리지 않는다. */
private void replacePublishedAssetReferences(PublishRequest request) {
String ownerType = ownerTypeOf(request.kind());
jdbcClient
.sql(
"DELETE FROM asset_reference WHERE owner_type = :ownerType AND owner_id = :id"
+ " AND reference_scope = 'PUBLISHED'")
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"INSERT INTO asset_reference (asset_id, owner_type, owner_id, reference_scope,"
+ " reference_role) VALUES (:assetId, :ownerType, :id, 'PUBLISHED', 'BODY')"
+ " ON CONFLICT DO NOTHING")
.param("assetId", entry.assetId())
.param("ownerType", ownerType)
.param("id", request.documentId())
.update();
}
}
/** 16. 최초 공개 시각. 이미 값이 있으면 덮지 않는다 — "처음"은 한 번뿐이다. */
private void markAssetsFirstPublished(PublishRequest request) {
for (AssetManifestEntry entry : request.assetManifest()) {
jdbcClient
.sql(
"UPDATE asset SET first_published_at = now()"
+ " WHERE id = :assetId AND first_published_at IS NULL")
.param("assetId", entry.assetId())
.update();
}
}
/** 18. source 쪽 게시 메타데이터. */
private void markSourcePublished(PublishRequest request) {
// switch 문이 아니라 식이다 — 열거 전부를 다루면 default 가 필요 없고, 유형이 늘면 컴파일러가
// 여기서 막아 준다(문이면 조용히 아무것도 안 하고 지나간다).
int updated =
switch (request.kind()) {
case CASE, REFERENCE ->
jdbcClient
.sql(
"UPDATE document SET workflow_status = 'PUBLISHED',"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
case QUESTION ->
jdbcClient
.sql(
"UPDATE open_question SET"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now() WHERE id = :id")
.param("id", request.documentId())
.update();
// project_decision 에는 게시 시각 컬럼이 없다. 게시 사실은 publication 이 소유하므로
// 여기서 억지로 컬럼을 만들지 않는다.
case PROJECT_DECISION -> 0;
};
if (updated == 0 && request.kind() != RecordKind.PROJECT_DECISION) {
throw StudioException.of(
StudioError.DOCUMENT_NOT_FOUND,
"the source record disappeared while publishing " + request.documentId());
}
}
private PublishResultView result(UUID publicationId, UUID eventId) {
PublicationAggregateView aggregate =
jdbcClient
.sql(
"SELECT publication_id, source_id, status, published_version,"
+ " publication_revision, latest_event_id, public_path, updated_at"
+ " FROM publication WHERE publication_id = :id")
.param("id", publicationId)
.query(JdbcPublicationWriterAdapter::mapAggregate)
.single();
PublicationEventView event =
jdbcClient
.sql(
"SELECT e.publication_event_id, e.publication_id, e.source_id, e.event_type,"
+ " e.occurred_at, e.published_version, e.source_published_event_id,"
+ " (s.publication_event_id IS NOT NULL) AS snapshot_available"
+ " FROM publication_event e"
+ " LEFT JOIN publication_snapshot s"
+ " ON s.publication_event_id = e.publication_event_id"
+ " WHERE e.publication_event_id = :id")
.param("id", eventId)
.query(PublicationRowMappers::mapEvent)
.single();
return new PublishResultView(aggregate, event);
}
/**
* 이 어댑터는 <b>열린 트랜잭션 안에서만</b> 올바르게 동작한다.
*
* <p>{@code publication.latest_event_id} 와 {@code publication_event.publication_id} 가 서로를 가리키고, 그
* 순환은 {@code fk_publication_latest_event} 의 {@code DEFERRABLE INITIALLY DEFERRED} 로만 성립한다. 지연 검사는
* <b>트랜잭션 끝</b>에 일어나므로, autocommit 이면 각 구문이 곧 트랜잭션이라 첫 INSERT 에서 바로 위반이 된다.
*
* <p>이 사실을 주석으로만 남기면 트랜잭션 없이 호출한 코드가 "외래 키 위반"이라는, 원인과 한참 떨어진 오류를 만난다. 통합 테스트를 처음 돌렸을 때 실제로 그렇게
* 실패했다. 그래서 전제를 여기서 확인하고 무엇이 잘못됐는지 그대로 말한다.
*/
private static void requireTransaction(String operation) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new IllegalStateException(
"publication "
+ operation
+ " must run inside an active transaction: publication and publication_event"
+ " reference each other, and that cycle only resolves at commit through"
+ " fk_publication_latest_event's deferred check");
}
}
static PublicationAggregateView mapAggregate(java.sql.ResultSet rs, int rowNum)
throws java.sql.SQLException {
return new PublicationAggregateView(
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationAggregateStatus.valueOf(rs.getString("status")),
rs.getLong("published_version"),
rs.getLong("publication_revision"),
rs.getObject("latest_event_id", UUID.class),
rs.getString("public_path"),
rs.getTimestamp("updated_at").toInstant());
}
private String manifestJson(List<AssetManifestEntry> manifest) {
ArrayNode array = objectMapper.createArrayNode();
for (AssetManifestEntry entry : manifest) {
ObjectNode node = array.addObject();
node.put("assetId", entry.assetId() == null ? null : entry.assetId().toString());
node.put("assetKey", entry.assetKey());
node.put("mediaType", entry.mediaType());
node.put("publicPath", entry.publicPath());
node.put("width", entry.width());
node.put("height", entry.height());
node.put("decorative", entry.decorative());
}
try {
return objectMapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise a publication asset manifest", e);
}
}
/** {@code asset_reference.owner_type} 은 {@code RecordKind} 와 이름이 다르다(V7). */
private static String ownerTypeOf(RecordKind kind) {
return switch (kind) {
case CASE, REFERENCE -> "DOCUMENT";
case QUESTION -> "QUESTION";
case PROJECT_DECISION -> "DECISION";
};
}
/** {@code public_route.slug} 는 경로가 아니라 마지막 조각이다. */
private static String slugOf(String publicPath) {
if (publicPath == null || publicPath.isBlank()) {
throw StudioException.of(
StudioError.DOCUMENT_VALIDATION_FAILED, "a published record needs a public path");
}
return publicPath.substring(publicPath.lastIndexOf('/') + 1);
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
private static String sha256(String value) {
try {
return HexFormat.of()
.formatHex(
MessageDigest.getInstance("SHA-256")
.digest(nullToEmpty(value).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 must be available on every supported JVM", e);
}
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication;
import dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView;
import dev.caskeleton.application.techlog.studio.model.PublicationEventView;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;
/** {@code publication_event} 행 매핑. writer 와 조회 어댑터가 같은 모양을 쓰도록 한 곳에 둔다. */
final class PublicationRowMappers {
private PublicationRowMappers() {}
static PublicationEventView mapEvent(ResultSet rs, int rowNum) throws SQLException {
return new PublicationEventView(
rs.getObject("publication_event_id", UUID.class),
rs.getObject("publication_id", UUID.class),
rs.getObject("source_id", UUID.class),
PublicationEventTypeView.valueOf(rs.getString("event_type")),
rs.getTimestamp("occurred_at").toInstant(),
rs.getLong("published_version"),
rs.getObject("source_published_event_id", UUID.class),
rs.getBoolean("snapshot_available"));
}
}
@@ -0,0 +1,255 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code CASE} / {@code REFERENCE} 편집본. 둘 다 {@code document} 루트 + 유형별 detail 테이블이다 (ADR-003, 설계
* 07장).
*/
final class DocumentWorkingCopyStore {
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
private final ProjectLinkStore projectLinks;
DocumentWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator,
ProjectLinkStore projectLinks) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
this.projectLinks = projectLinks;
}
Optional<WorkingCopyView> find(RecordKind kind, UUID id) {
return jdbcClient
.sql(
"SELECT d.id, d.version, d.updated_at, d.title, d.slug, d.summary, d.primary_topic_id,"
+ " d.body_markdown, d.last_verified_at,"
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
+ " r.scope_summary, r.rules, r.applies_to, r.excluded_scope, r.examples"
+ " FROM document d"
+ " LEFT JOIN case_detail c ON c.document_id = d.id"
+ " LEFT JOIN reference_detail r ON r.document_id = d.id"
+ " WHERE d.id = :id AND d.document_type = :type")
.param("id", id)
.param("type", kind.name())
.query(
(rs, rowNum) -> {
UUID documentId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
kind,
rs.getString("title"),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
projectLinks.findPrimaryProjectForDocument(documentId).orElse(null),
relations.findBySource(kind, documentId));
long version = rs.getLong("version");
var updatedAt = instant(rs, "updated_at");
if (kind == RecordKind.CASE) {
return (WorkingCopyView)
new WorkingCopyView.CaseWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("problem_summary")),
orEmpty(rs.getString("conclusion_summary")),
orEmpty(rs.getString("environment")),
orEmpty(rs.getString("reproduction")),
dateFromTimestamp(rs, "last_verified_at"),
orEmpty(rs.getString("body_markdown")));
}
return (WorkingCopyView)
new WorkingCopyView.ReferenceWorkingCopyView(
documentId,
version,
updatedAt,
base,
orEmpty(rs.getString("scope_summary")),
json.rulesFromJson(rs.getString("rules")),
json.orderedTextFromJson(rs.getString("applies_to")),
json.orderedTextFromJson(rs.getString("excluded_scope")),
json.orderedTextFromJson(rs.getString("examples")),
dateFromTimestamp(rs, "last_verified_at"));
})
.optional();
}
UUID create(WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
jdbcClient
.sql(
"INSERT INTO document (id, document_type, slug, title, summary, body_markdown,"
+ " primary_topic_id, last_verified_at, version, created_by, updated_by)"
+ " VALUES (:id, :type, :slug, :title, :summary, :body, :topicId, :verifiedAt,"
// 계약의 WorkingCopyBase.version 은 minimum 1 이다. 컬럼 기본값 0 을 그대로 두면
// 생성 직후 응답이 계약을 위반한다.
+ " 1, :principal, :principal)")
.param("id", id)
.param("type", kind.name())
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.update();
insertDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return id;
}
/**
* 낙관적 잠금 저장.
*
* @return 갱신된 행이 없으면(= {@code expectedVersion} 불일치) {@code false}
*/
boolean save(UUID id, long expectedVersion, WorkingCopyInputView input, String principal) {
RecordKind kind = input.kind();
WorkingCopyBaseInput base = input.base();
int updated =
jdbcClient
.sql(
"UPDATE document SET slug = :slug, title = :title, summary = :summary,"
+ " body_markdown = :body, primary_topic_id = :topicId,"
+ " last_verified_at = :verifiedAt, version = version + 1,"
+ " updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND document_type = :type AND version = :expectedVersion")
.param("slug", slugToColumn(base.slug()))
.param("title", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("body", bodyMarkdownOf(input))
.param("topicId", base.topicId())
.param("verifiedAt", dateToTimestamp(verifiedOnOf(input)))
.param("principal", principal)
.param("id", id)
.param("type", kind.name())
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
updateDetail(id, input);
relations.replace(kind, id, base.relations());
projectLinks.setPrimaryProjectForDocument(id, base.projectId());
return true;
}
private void insertDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"INSERT INTO case_detail (document_id, document_type, problem_summary,"
+ " conclusion_summary, environment, reproduction)"
+ " VALUES (:id, 'CASE', :problem, :conclusion, :environment, :reproduction)")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"INSERT INTO reference_detail (document_id, document_type, scope_summary,"
+ " rules, applies_to, excluded_scope, examples)"
+ " VALUES (:id, 'REFERENCE', :purpose, CAST(:rules AS jsonb),"
+ " CAST(:applyWhen AS jsonb), CAST(:exceptions AS jsonb),"
+ " CAST(:examples AS jsonb))")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
private void updateDetail(UUID id, WorkingCopyInputView input) {
switch (input) {
case WorkingCopyInputView.CaseInputView caseInput ->
jdbcClient
.sql(
"UPDATE case_detail SET problem_summary = :problem,"
+ " conclusion_summary = :conclusion, environment = :environment,"
+ " reproduction = :reproduction WHERE document_id = :id")
.param("id", id)
.param("problem", orEmpty(caseInput.problem()))
.param("conclusion", orEmpty(caseInput.conclusion()))
.param("environment", orEmpty(caseInput.environment()))
.param("reproduction", orEmpty(caseInput.reproduction()))
.update();
case WorkingCopyInputView.ReferenceInputView reference ->
jdbcClient
.sql(
"UPDATE reference_detail SET scope_summary = :purpose,"
+ " rules = CAST(:rules AS jsonb), applies_to = CAST(:applyWhen AS jsonb),"
+ " excluded_scope = CAST(:exceptions AS jsonb),"
+ " examples = CAST(:examples AS jsonb) WHERE document_id = :id")
.param("id", id)
.param("purpose", orEmpty(reference.purpose()))
.param("rules", json.rulesToJson(reference.rules()))
.param("applyWhen", json.orderedTextToJson(reference.applyWhen()))
.param("exceptions", json.orderedTextToJson(reference.exceptions()))
.param("examples", json.orderedTextToJson(reference.examples()))
.update();
default ->
throw new IllegalArgumentException("not a document-backed working copy: " + input.kind());
}
}
/** {@code REFERENCE}는 계약에 본문이 없다 — 컬럼이 NOT NULL이므로 빈 문자열을 유지한다. */
private static String bodyMarkdownOf(WorkingCopyInputView input) {
return input instanceof WorkingCopyInputView.CaseInputView caseInput
? orEmpty(caseInput.bodyMarkdown())
: "";
}
/** 계약의 {@code lastVerifiedOn}(CASE) / {@code verifiedOn}(REFERENCE)은 같은 컬럼에 담긴다. */
private static java.time.LocalDate verifiedOnOf(WorkingCopyInputView input) {
return switch (input) {
case WorkingCopyInputView.CaseInputView caseInput -> caseInput.lastVerifiedOn();
case WorkingCopyInputView.ReferenceInputView reference -> reference.verifiedOn();
default -> null;
};
}
List<RelationView> relationsOf(RecordKind kind, UUID id) {
return relations.findBySource(kind, id);
}
}
@@ -0,0 +1,128 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.WorkingCopyRepositoryPort;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import tools.jackson.databind.ObjectMapper;
/**
* 계약의 통합 {@code WorkingCopy}를 네 source aggregate로 dispatch한다.
*
* <p>공통 CRUD repository가 아니다 — {@code kind}마다 소유 테이블이 다르고, 그 구분을 유지하는 것이 ADR-003의 결정이다. 여기서 하는 일은
* "어느 저장소로 보낼지" 뿐이다.
*
* <p>JPA 엔티티가 아니라 {@code JdbcClient}를 쓴다. 이 네 aggregate는 Studio 저장 경로에서만 쓰이고, 낙관적 잠금은 {@code UPDATE
* ... WHERE version = :expectedVersion}의 갱신 행 수로 정확히 같은 의미를 얻는다 — 여덟 개 넘는 테이블에 엔티티와 매핑을 세우는 비용에
* 상응하는 이득이 없다. 포트 계약이 같으므로 나중에 JPA 가 필요해지면 이 어댑터만 바뀐다.
*/
@Repository
public class JdbcWorkingCopyRepositoryAdapter implements WorkingCopyRepositoryPort {
private final JdbcClient jdbcClient;
private final DocumentWorkingCopyStore documents;
private final QuestionWorkingCopyStore questions;
private final ProjectDecisionWorkingCopyStore decisions;
/**
* 생성자가 둘이라 Spring 이 어느 쪽을 쓸지 스스로 정하지 못한다 — 표시가 없으면 기본 생성자를 찾다 실패해 컨텍스트가 뜨지 않는다(실제로 부팅 검증에서 그렇게
* 실패했다). 두 번째 생성자는 테스트가 id 생성기를 주입하기 위한 것이며 프로덕션 배선은 항상 이쪽이다.
*/
@Autowired
public JdbcWorkingCopyRepositoryAdapter(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this(jdbcClient, objectMapper, UUID::randomUUID);
}
JdbcWorkingCopyRepositoryAdapter(
JdbcClient jdbcClient, ObjectMapper objectMapper, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
StudioJson json = new StudioJson(objectMapper);
StudioRelationStore relations = new StudioRelationStore(jdbcClient, idGenerator);
ProjectLinkStore projectLinks = new ProjectLinkStore(jdbcClient);
this.documents =
new DocumentWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
this.questions =
new QuestionWorkingCopyStore(jdbcClient, relations, json, idGenerator, projectLinks);
this.decisions = new ProjectDecisionWorkingCopyStore(jdbcClient, relations, json, idGenerator);
}
/**
* 계약상 {@code documentId}는 source aggregate id 그대로다 — 어느 테이블에 있는지 먼저 찾아야 한다 (spec §7.2
* StudioDocumentLocator). 세 테이블을 UNION 으로 한 번에 본다.
*/
@Override
public Optional<RecordKind> findKind(UUID documentId) {
if (documentId == null) {
return Optional.empty();
}
return jdbcClient
.sql(
"SELECT document_type AS kind FROM document WHERE id = :id"
+ " UNION ALL SELECT 'QUESTION' FROM open_question WHERE id = :id"
+ " UNION ALL SELECT 'PROJECT_DECISION' FROM project_decision WHERE id = :id")
.param("id", documentId)
.query(String.class)
.optional()
.map(RecordKind::valueOf);
}
@Override
public Optional<WorkingCopyView> find(UUID documentId) {
return findKind(documentId).flatMap(kind -> load(kind, documentId));
}
@Override
public WorkingCopyView create(WorkingCopyInputView input, String principal) {
UUID id =
switch (input) {
case WorkingCopyInputView.CaseInputView ignored -> documents.create(input, principal);
case WorkingCopyInputView.ReferenceInputView ignored ->
documents.create(input, principal);
case WorkingCopyInputView.QuestionInputView question ->
questions.create(question, principal);
case WorkingCopyInputView.ProjectDecisionInputView decision ->
decisions.create(decision, principal);
};
return load(input.kind(), id)
.orElseThrow(
() ->
StudioException.of(
StudioError.STUDIO_UNAVAILABLE,
"the working copy "
+ id
+ " could not be read back right after it was created"));
}
@Override
public Optional<WorkingCopyView> save(
UUID documentId, long expectedVersion, WorkingCopyInputView input, String principal) {
boolean saved =
switch (input) {
case WorkingCopyInputView.CaseInputView ignored ->
documents.save(documentId, expectedVersion, input, principal);
case WorkingCopyInputView.ReferenceInputView ignored ->
documents.save(documentId, expectedVersion, input, principal);
case WorkingCopyInputView.QuestionInputView question ->
questions.save(documentId, expectedVersion, question, principal);
case WorkingCopyInputView.ProjectDecisionInputView decision ->
decisions.save(documentId, expectedVersion, decision, principal);
};
return saved ? load(input.kind(), documentId) : Optional.empty();
}
private Optional<WorkingCopyView> load(RecordKind kind, UUID id) {
return switch (kind) {
case CASE, REFERENCE -> documents.find(kind, id);
case QUESTION -> questions.find(id);
case PROJECT_DECISION -> decisions.find(id);
};
}
}
@@ -0,0 +1,180 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateFromTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.dateToTimestamp;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code PROJECT_DECISION} 편집본 ({@code project_decision}).
*
* <p>계약의 {@code ADOPTED}는 Domain의 {@code ACCEPTED}다(ADR-003) — UI 용어 때문에 Domain enum을 바꾸지 않고 여기서
* 변환한다. {@code supersede}/{@code reject}는 secondary management 계약이 소유하므로 이 저장 경로가 그 두 상태를 만들지도,
* 건드리지도 않는다.
*/
final class ProjectDecisionWorkingCopyStore {
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
ProjectDecisionWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
}
Optional<WorkingCopyView> find(UUID id) {
return jdbcClient
.sql(
"SELECT id, version, updated_at, title, slug, summary, primary_topic_id, project_id,"
+ " decision_status, decided_at, statement, rationale_markdown, consequences"
+ " FROM project_decision WHERE id = :id")
.param("id", id)
.query(
(rs, rowNum) -> {
UUID decisionId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
RecordKind.PROJECT_DECISION,
orEmpty(rs.getString("title")),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
rs.getObject("project_id", UUID.class),
relations.findBySource(RecordKind.PROJECT_DECISION, decisionId));
return (WorkingCopyView)
new WorkingCopyView.ProjectDecisionWorkingCopyView(
decisionId,
rs.getLong("version"),
instant(rs, "updated_at"),
base,
toContractStatus(rs.getString("decision_status")),
dateFromTimestamp(rs, "decided_at"),
orEmpty(rs.getString("statement")),
orEmpty(rs.getString("rationale_markdown")),
json.orderedTextFromJson(rs.getString("consequences")));
})
.optional();
}
UUID create(WorkingCopyInputView.ProjectDecisionInputView input, String principal) {
requireDecidedOnWhenAdopted(input);
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
jdbcClient
.sql(
"INSERT INTO project_decision (id, project_id, title, slug, summary,"
+ " primary_topic_id, statement, rationale_markdown, consequences,"
+ " decision_status, decided_at, version, created_by, updated_by)"
+ " VALUES (:id, :projectId, :title, :slug, :summary, :topicId, :statement,"
+ " :rationale, CAST(:consequences AS jsonb), :status, :decidedAt, 1,"
+ " :principal, :principal)")
.param("id", id)
.param("projectId", base.projectId())
.param("title", orEmpty(base.title()))
.param("slug", slugToColumn(base.slug()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("statement", orEmpty(input.statement()))
.param("rationale", orEmpty(input.rationale()))
.param("consequences", json.orderedTextToJson(input.consequences()))
.param("status", toDomainStatus(input.decisionStatus()))
.param("decidedAt", dateToTimestamp(input.decidedOn()))
.param("principal", principal)
.update();
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
return id;
}
boolean save(
UUID id,
long expectedVersion,
WorkingCopyInputView.ProjectDecisionInputView input,
String principal) {
requireDecidedOnWhenAdopted(input);
WorkingCopyBaseInput base = input.base();
int updated =
jdbcClient
.sql(
"UPDATE project_decision SET project_id = :projectId, title = :title,"
+ " slug = :slug, summary = :summary, primary_topic_id = :topicId,"
+ " statement = :statement, rationale_markdown = :rationale,"
+ " consequences = CAST(:consequences AS jsonb),"
// SUPERSEDED/REJECTED 는 secondary management 계약이 소유한다. Studio 저장이
// 그 상태를 PROPOSED/ACCEPTED 로 되돌리면 그쪽 lifecycle 이 조용히 무효화된다.
+ " decision_status = CASE WHEN decision_status IN ('PROPOSED', 'ACCEPTED')"
+ " THEN :status ELSE decision_status END,"
+ " decided_at = :decidedAt,"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("projectId", base.projectId())
.param("title", orEmpty(base.title()))
.param("slug", slugToColumn(base.slug()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("statement", orEmpty(input.statement()))
.param("rationale", orEmpty(input.rationale()))
.param("consequences", json.orderedTextToJson(input.consequences()))
.param("status", toDomainStatus(input.decisionStatus()))
.param("decidedAt", dateToTimestamp(input.decidedOn()))
.param("principal", principal)
.param("id", id)
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
relations.replace(RecordKind.PROJECT_DECISION, id, base.relations());
return true;
}
/**
* {@code ck_project_decision_status_fields}는 {@code ACCEPTED}에 {@code decided_at}을 요구한다. 값을 지어내
* 채우면 사용자가 정하지 않은 날짜가 기록되고, 그냥 보내면 DB 제약 위반이 500으로 나간다 — 무엇이 빠졌는지 알려주고 거절한다.
*/
private static void requireDecidedOnWhenAdopted(
WorkingCopyInputView.ProjectDecisionInputView input) {
if (input.decisionStatus() == DecisionStatusView.ADOPTED && input.decidedOn() == null) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"document.decidedOn is required when decisionStatus is ADOPTED");
}
}
private static String toDomainStatus(DecisionStatusView status) {
return status == DecisionStatusView.ADOPTED ? "ACCEPTED" : "PROPOSED";
}
private static DecisionStatusView toContractStatus(String domainStatus) {
return switch (domainStatus) {
case "ACCEPTED" -> DecisionStatusView.ADOPTED;
case "PROPOSED" -> DecisionStatusView.PROPOSED;
// SUPERSEDED / REJECTED 는 계약의 두 값 어디에도 대응하지 않는다. 계약은 null 을 허용하므로
// 억지로 가장 가까운 값으로 접지 않고 "이 축약 view 로는 표현할 수 없음"을 null 로 알린다.
default -> null;
};
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 계약의 단일 {@code projectId}를 설계 스키마의 링크 테이블({@code project_document_link} / {@code
* project_question_link})에 옮긴다.
*
* <p>링크 테이블은 한 문서가 여러 프로젝트에 붙는 것을 허용하지만 Studio 편집기는 프로젝트 하나만 다룬다. Studio가 소유하는 것은 {@code PRIMARY}
* 링크 하나뿐이며, {@code RELATED} 링크는 건드리지 않는다 — 그건 다른 화면의 데이터이고 Studio 저장이 지워도 되는 것이 아니다.
*/
final class ProjectLinkStore {
private static final String PRIMARY = "PRIMARY";
private final JdbcClient jdbcClient;
ProjectLinkStore(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
Optional<UUID> findPrimaryProjectForDocument(UUID documentId) {
return findPrimary("project_document_link", "document_id", documentId);
}
Optional<UUID> findPrimaryProjectForQuestion(UUID questionId) {
return findPrimary("project_question_link", "question_id", questionId);
}
void setPrimaryProjectForDocument(UUID documentId, UUID projectId) {
setPrimary("project_document_link", "document_id", documentId, projectId);
}
void setPrimaryProjectForQuestion(UUID questionId, UUID projectId) {
setPrimary("project_question_link", "question_id", questionId, projectId);
}
private Optional<UUID> findPrimary(String table, String column, UUID id) {
return jdbcClient
.sql(
"SELECT project_id FROM "
+ table
+ " WHERE "
+ column
+ " = :id AND relation_type = :type")
.param("id", id)
.param("type", PRIMARY)
.query(UUID.class)
.optional();
}
private void setPrimary(String table, String column, UUID id, UUID projectId) {
jdbcClient
.sql("DELETE FROM " + table + " WHERE " + column + " = :id AND relation_type = :type")
.param("id", id)
.param("type", PRIMARY)
.update();
if (projectId == null) {
return;
}
jdbcClient
.sql(
"INSERT INTO "
+ table
+ " (project_id, "
+ column
+ ", relation_type)"
+ " VALUES (:projectId, :id, :type)"
// PK 는 (project_id, <column>) 이라 같은 쌍이 RELATED 로 이미 있으면 INSERT 가 깨진다.
// Studio 가 소유하는 것은 PRIMARY 이므로 그 경우 관계 종류를 올려준다.
+ " ON CONFLICT (project_id, "
+ column
+ ") DO UPDATE SET relation_type = :type")
.param("projectId", projectId)
.param("id", id)
.param("type", PRIMARY)
.update();
}
}
@@ -0,0 +1,296 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.instant;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.orEmpty;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugFromColumn;
import static dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.StudioSqlSupport.slugToColumn;
import dev.caskeleton.application.techlog.error.StudioError;
import dev.caskeleton.application.techlog.error.StudioException;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionResolutionView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* {@code QUESTION} 편집본 ({@code open_question} + {@code question_point}).
*
* <p>계약의 {@code questionStatus}는 Domain lifecycle의 축약 view다(ADR-003) — {@code OPEN}을 받았다고 Domain의
* {@code INVESTIGATING}/{@code PAUSED}를 덮어쓰지 않는다. 그렇게 하면 조사 이력이 사라진다.
*/
final class QuestionWorkingCopyStore {
/**
* 계약의 {@code QuestionResolution}에는 resolution_type이 없는데 {@code
* ck_question_resolution_consistency}는 RESOLVED에 그 값을 요구한다. Studio 편집으로 해결 처리된 질문은 "판단을 내렸다"로
* 기록한다 — 나머지 세 유형(가정 기각/질문 재정의/무의미해짐)은 secondary management 계약의 명시적 action이 소유한다.
*/
private static final String DEFAULT_RESOLUTION_TYPE = "DECISION_MADE";
private final JdbcClient jdbcClient;
private final StudioRelationStore relations;
private final StudioJson json;
private final Supplier<UUID> idGenerator;
private final ProjectLinkStore projectLinks;
QuestionWorkingCopyStore(
JdbcClient jdbcClient,
StudioRelationStore relations,
StudioJson json,
Supplier<UUID> idGenerator,
ProjectLinkStore projectLinks) {
this.jdbcClient = jdbcClient;
this.relations = relations;
this.json = json;
this.idGenerator = idGenerator;
this.projectLinks = projectLinks;
}
Optional<WorkingCopyView> find(UUID id) {
return jdbcClient
.sql(
"SELECT id, version, updated_at, question, slug, summary, primary_topic_id,"
+ " question_status, next_verification, options, resolution_summary,"
+ " resolution_evidence_target_id, resolution_link_label"
+ " FROM open_question WHERE id = :id")
.param("id", id)
.query(
(rs, rowNum) -> {
UUID questionId = rs.getObject("id", UUID.class);
WorkingCopyBaseInput base =
new WorkingCopyBaseInput(
RecordKind.QUESTION,
rs.getString("question"),
slugFromColumn(rs.getString("slug")),
orEmpty(rs.getString("summary")),
rs.getObject("primary_topic_id", UUID.class),
projectLinks.findPrimaryProjectForQuestion(questionId).orElse(null),
relations.findBySource(RecordKind.QUESTION, questionId));
String domainStatus = rs.getString("question_status");
return (WorkingCopyView)
new WorkingCopyView.QuestionWorkingCopyView(
questionId,
rs.getLong("version"),
instant(rs, "updated_at"),
base,
toContractStatus(domainStatus),
points(questionId, "FACT"),
points(questionId, "ASSUMPTION"),
points(questionId, "UNKNOWN"),
points(questionId, "CONSTRAINT"),
json.optionsFromJson(rs.getString("options")),
orEmpty(rs.getString("next_verification")),
resolutionOf(
domainStatus,
rs.getString("resolution_summary"),
rs.getObject("resolution_evidence_target_id", UUID.class),
rs.getString("resolution_link_label")));
})
.optional();
}
UUID create(WorkingCopyInputView.QuestionInputView input, String principal) {
UUID id = idGenerator.get();
WorkingCopyBaseInput base = input.base();
boolean resolved = input.questionStatus() == QuestionStatusView.RESOLVED;
requireUsableResolution(input, resolved);
jdbcClient
.sql(
"INSERT INTO open_question (id, slug, question, summary, primary_topic_id,"
+ " next_verification, options, question_status, resolution_type,"
+ " resolution_summary, resolution_evidence_target_id, resolution_link_label,"
+ " resolved_at, version, created_by, updated_by)"
+ " VALUES (:id, :slug, :question, :summary, :topicId, :nextValidation,"
+ " CAST(:options AS jsonb), :status, :resolutionType, :resolutionSummary,"
+ " :evidenceTargetId, :linkLabel,"
// 해결 시각은 애플리케이션 시계가 아니라 DB 시계로 찍는다 — 같은 트랜잭션의
// 다른 타임스탬프(created_at/updated_at)와 기준이 같아야 순서가 뒤집히지 않는다.
+ " CASE WHEN :resolved THEN now() ELSE NULL END, 1, :principal, :principal)")
.param("id", id)
.param("slug", slugToColumn(base.slug()))
.param("question", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("nextValidation", orEmpty(input.nextValidation()))
.param("options", json.optionsToJson(input.options()))
.param("status", resolved ? "RESOLVED" : "OPEN")
.param("resolutionType", resolved ? DEFAULT_RESOLUTION_TYPE : null)
.param("resolutionSummary", resolved ? input.resolution().summary() : null)
.param("evidenceTargetId", resolved ? input.resolution().evidenceTargetId() : null)
.param("linkLabel", resolved ? orEmpty(input.resolution().linkLabel()) : "")
.param("resolved", resolved)
.param("principal", principal)
.update();
replacePoints(id, input);
relations.replace(RecordKind.QUESTION, id, base.relations());
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
return id;
}
boolean save(
UUID id,
long expectedVersion,
WorkingCopyInputView.QuestionInputView input,
String principal) {
WorkingCopyBaseInput base = input.base();
String storedStatus =
jdbcClient
.sql("SELECT question_status FROM open_question WHERE id = :id")
.param("id", id)
.query(String.class)
.optional()
.orElse("OPEN");
boolean resolveNow =
input.questionStatus() == QuestionStatusView.RESOLVED && !"RESOLVED".equals(storedStatus);
requireUsableResolution(input, input.questionStatus() == QuestionStatusView.RESOLVED);
// ADR-003: 축약 상태가 Domain lifecycle 을 덮어쓰지 않는다. OPEN 계열 안에서의 값 변화는 무시하고,
// 이미 RESOLVED 인 질문을 OPEN 으로 되돌리는 것도 저장이 할 일이 아니다 — reopen 은 secondary
// management 계약의 명시적 action 이다.
String nextStatus = resolveNow ? "RESOLVED" : storedStatus;
boolean resolvedAfter = "RESOLVED".equals(nextStatus);
int updated =
jdbcClient
.sql(
"UPDATE open_question SET slug = :slug, question = :question, summary = :summary,"
+ " primary_topic_id = :topicId, next_verification = :nextValidation,"
+ " options = CAST(:options AS jsonb), question_status = :status,"
+ " resolution_type = CASE WHEN :resolved THEN"
+ " COALESCE(resolution_type, :resolutionType) ELSE resolution_type END,"
+ " resolution_summary = CASE WHEN :resolved THEN :resolutionSummary"
+ " ELSE resolution_summary END,"
+ " resolution_evidence_target_id = CASE WHEN :resolved THEN :evidenceTargetId"
+ " ELSE resolution_evidence_target_id END,"
+ " resolution_link_label = CASE WHEN :resolved THEN :linkLabel"
+ " ELSE resolution_link_label END,"
+ " resolved_at = CASE WHEN :resolved THEN COALESCE(resolved_at, now())"
+ " ELSE resolved_at END,"
+ " version = version + 1, updated_at = now(), updated_by = :principal"
+ " WHERE id = :id AND version = :expectedVersion")
.param("slug", slugToColumn(base.slug()))
.param("question", orEmpty(base.title()))
.param("summary", orEmpty(base.summary()))
.param("topicId", base.topicId())
.param("nextValidation", orEmpty(input.nextValidation()))
.param("options", json.optionsToJson(input.options()))
.param("status", nextStatus)
.param("resolved", resolvedAfter)
.param("resolutionType", DEFAULT_RESOLUTION_TYPE)
.param("resolutionSummary", resolvedAfter ? input.resolution().summary() : null)
.param("evidenceTargetId", resolvedAfter ? input.resolution().evidenceTargetId() : null)
.param("linkLabel", resolvedAfter ? orEmpty(input.resolution().linkLabel()) : "")
.param("principal", principal)
.param("id", id)
.param("expectedVersion", expectedVersion)
.update();
if (updated == 0) {
return false;
}
replacePoints(id, input);
relations.replace(RecordKind.QUESTION, id, base.relations());
projectLinks.setPrimaryProjectForQuestion(id, base.projectId());
return true;
}
/**
* {@code RESOLVED}는 {@code ck_question_resolution_consistency}가 요약을 요구한다. 요약 없이 해결 처리된 질문은 "왜
* 끝났는지 모르는 종료"라 저장을 거부한다 — 여기서 거부하지 않으면 DB 제약 위반이 500으로 나가 클라이언트가 무엇이 잘못됐는지 알 수 없다.
*/
private static void requireUsableResolution(
WorkingCopyInputView.QuestionInputView input, boolean resolved) {
if (!resolved) {
return;
}
QuestionResolutionView resolution = input.resolution();
if (resolution == null || resolution.summary() == null || resolution.summary().isBlank()) {
throw StudioException.of(
StudioError.REQUEST_VALIDATION_FAILED,
"document.resolution.summary is required when questionStatus is RESOLVED");
}
}
private static QuestionStatusView toContractStatus(String domainStatus) {
return "RESOLVED".equals(domainStatus) ? QuestionStatusView.RESOLVED : QuestionStatusView.OPEN;
}
private static QuestionResolutionView resolutionOf(
String domainStatus, String summary, UUID evidenceTargetId, String linkLabel) {
if (!"RESOLVED".equals(domainStatus)) {
return null;
}
return new QuestionResolutionView(orEmpty(summary), evidenceTargetId, orEmpty(linkLabel));
}
private List<OrderedTextView> points(UUID questionId, String pointKind) {
return jdbcClient
.sql(
"SELECT id, content, display_order FROM question_point"
+ " WHERE question_id = :id AND point_kind = :kind ORDER BY display_order")
.param("id", questionId)
.param("kind", pointKind)
.query(
(rs, rowNum) ->
new OrderedTextView(
rs.getObject("id", UUID.class),
rs.getString("content"),
rs.getInt("display_order")))
.list();
}
private void replacePoints(UUID questionId, WorkingCopyInputView.QuestionInputView input) {
// 이 질문이 지금 소유한 point id. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다 — 남의 질문 것을
// 그대로 쓰면 PK 가 충돌하고, 매번 새로 부여하면 편집기의 줄 식별자가 저장마다 바뀐다.
Set<UUID> owned =
Set.copyOf(
jdbcClient
.sql("SELECT id FROM question_point WHERE question_id = :id")
.param("id", questionId)
.query(UUID.class)
.list());
jdbcClient
.sql("DELETE FROM question_point WHERE question_id = :id")
.param("id", questionId)
.update();
insertPoints(questionId, "FACT", input.facts(), owned);
insertPoints(questionId, "ASSUMPTION", input.assumptions(), owned);
insertPoints(questionId, "UNKNOWN", input.unknowns(), owned);
insertPoints(questionId, "CONSTRAINT", input.constraints(), owned);
}
private void insertPoints(
UUID questionId, String pointKind, List<OrderedTextView> items, Set<UUID> owned) {
int order = 0;
for (OrderedTextView item : items) {
// content 는 CHECK (length(trim(content)) > 0) 다. 빈 줄은 편집 중 흔한 상태이므로 거부하는 대신
// 저장하지 않는다 — 계약도 OrderedText.text 에 minLength 1 을 두어 빈 항목을 보내지 말라고 한다.
if (item.text() == null || item.text().isBlank()) {
continue;
}
jdbcClient
.sql(
"INSERT INTO question_point (id, question_id, point_kind, content, display_order)"
+ " VALUES (:id, :questionId, :kind, :content, :order)")
.param(
"id",
(item.id() != null && owned.contains(item.id())) ? item.id() : idGenerator.get())
.param("questionId", questionId)
.param("kind", pointKind)
.param("content", item.text())
.param("order", order++)
.update();
}
}
}
@@ -0,0 +1,131 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.QuestionOptionView;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.shared.error.MappingException;
import java.util.List;
import java.util.UUID;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* 설계 스키마가 jsonb 배열로 정한 순서 있는 항목들을 읽고 쓴다.
*
* <p>Jackson의 자동 POJO 바인딩을 쓰지 않고 필드를 직접 읽고 쓴다. 이 값들은 <b>DB에 영속되는 형태</b>라 application record의 필드 이름이
* 바뀌면 이미 저장된 행을 읽지 못하게 된다 — 그 결합을 만들지 않으려고 컬럼 안의 key 이름을 여기서 명시적으로 고정한다.
*/
final class StudioJson {
private final ObjectMapper mapper;
StudioJson(ObjectMapper mapper) {
this.mapper = mapper;
}
String orderedTextToJson(List<OrderedTextView> items) {
ArrayNode array = mapper.createArrayNode();
for (OrderedTextView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("text", item.text());
node.put("order", item.order());
}
return write(array);
}
List<OrderedTextView> orderedTextFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new OrderedTextView(
uuid(node, "id"), text(node, "text"), node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(OrderedTextView::order))
.toList();
}
String rulesToJson(List<ReferenceRuleView> items) {
ArrayNode array = mapper.createArrayNode();
for (ReferenceRuleView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("title", item.title());
node.put("body", item.body());
node.put("order", item.order());
}
return write(array);
}
List<ReferenceRuleView> rulesFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new ReferenceRuleView(
uuid(node, "id"),
text(node, "title"),
text(node, "body"),
node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(ReferenceRuleView::order))
.toList();
}
String optionsToJson(List<QuestionOptionView> items) {
ArrayNode array = mapper.createArrayNode();
for (QuestionOptionView item : items) {
ObjectNode node = array.addObject();
node.put("id", item.id() == null ? null : item.id().toString());
node.put("title", item.title());
node.put("description", item.description());
node.put("order", item.order());
}
return write(array);
}
List<QuestionOptionView> optionsFromJson(String json) {
return read(json)
.valueStream()
.map(
node ->
new QuestionOptionView(
uuid(node, "id"),
text(node, "title"),
text(node, "description"),
node.path("order").asInt(0)))
.sorted(java.util.Comparator.comparingInt(QuestionOptionView::order))
.toList();
}
private String write(ArrayNode array) {
try {
return mapper.writeValueAsString(array);
} catch (JacksonException e) {
throw new MappingException("failed to serialise a Studio jsonb column", e);
}
}
private JsonNode read(String json) {
if (json == null || json.isBlank()) {
return mapper.createArrayNode();
}
try {
JsonNode node = mapper.readTree(json);
return node.isArray() ? node : mapper.createArrayNode();
} catch (JacksonException e) {
throw new MappingException("failed to read a Studio jsonb column", e);
}
}
private static UUID uuid(JsonNode node, String field) {
String value = node.path(field).asString(null);
return (value == null || value.isBlank()) ? null : UUID.fromString(value);
}
private static String text(JsonNode node, String field) {
return node.path(field).asString("");
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.springframework.jdbc.core.simple.JdbcClient;
/**
* 네 유형이 공유하는 편집용 관계({@code studio_relation})를 읽고 통째로 교체한다.
*
* <p>부분 갱신이 아니라 교체인 이유: 계약의 {@code relations}는 배열 전체가 편집 대상이고, 클라이언트가 보낸 배열이 곧 최종 상태다. 지운 줄을 알아내려고
* diff를 뜨면 순서 재배열과 삭제를 구분하지 못한다.
*/
final class StudioRelationStore {
private final JdbcClient jdbcClient;
private final Supplier<UUID> idGenerator;
StudioRelationStore(JdbcClient jdbcClient, Supplier<UUID> idGenerator) {
this.jdbcClient = jdbcClient;
this.idGenerator = idGenerator;
}
List<RelationView> findBySource(RecordKind kind, UUID sourceId) {
return jdbcClient
.sql(
"SELECT id, target_id, reason, display_order FROM studio_relation "
+ "WHERE source_kind = :kind AND source_id = :sourceId ORDER BY display_order")
.param("kind", kind.name())
.param("sourceId", sourceId)
.query(
(rs, rowNum) ->
new RelationView(
rs.getObject("id", UUID.class),
rs.getObject("target_id", UUID.class),
rs.getString("reason"),
rs.getInt("display_order")))
.list();
}
void replace(RecordKind kind, UUID sourceId, List<RelationView> relations) {
// 이 source 가 지금 소유한 관계 id 집합. 클라이언트가 보낸 id 중 여기 있는 것만 유지한다.
Set<UUID> owned =
Set.copyOf(
jdbcClient
.sql(
"SELECT id FROM studio_relation "
+ "WHERE source_kind = :kind AND source_id = :sourceId")
.param("kind", kind.name())
.param("sourceId", sourceId)
.query(UUID.class)
.list());
jdbcClient
.sql("DELETE FROM studio_relation WHERE source_kind = :kind AND source_id = :sourceId")
.param("kind", kind.name())
.param("sourceId", sourceId)
.update();
for (RelationView relation : relations) {
// 이 source 가 원래 갖고 있던 id 는 그대로 둔다 — 편집기가 줄을 식별하는 키라 매 저장마다 바뀌면
// 화면의 줄이 통째로 갈아엎어진 것처럼 보인다. 그 밖의 id(남의 문서 것이거나 클라이언트가 지어낸
// 것)는 신뢰하지 않고 새로 부여한다 — 그대로 쓰면 다른 문서의 관계 행과 PK 가 충돌한다.
UUID id =
(relation.id() != null && owned.contains(relation.id()))
? relation.id()
: idGenerator.get();
jdbcClient
.sql(
"INSERT INTO studio_relation "
+ "(id, source_kind, source_id, target_id, reason, display_order) "
+ "VALUES (:id, :kind, :sourceId, :targetId, :reason, :order)")
.param("id", id)
.param("kind", kind.name())
.param("sourceId", sourceId)
.param("targetId", relation.targetId())
.param("reason", relation.reason() == null ? "" : relation.reason())
.param("order", relation.order())
.update();
}
}
void deleteBySource(RecordKind kind, UUID sourceId) {
replace(kind, sourceId, List.of());
}
}
@@ -0,0 +1,49 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
/** 계약 타입과 컬럼 타입 사이의 되풀이되는 변환. */
final class StudioSqlSupport {
private StudioSqlSupport() {}
/**
* 계약의 빈 slug("아직 정하지 않음")를 NULL로 옮긴다. 빈 문자열을 그대로 넣으면 {@code ck_document_slug_non_blank} 계열 제약에
* 걸리고, 무엇보다 여러 초안이 같은 빈 slug를 갖는 순간 slug UNIQUE 제약이 두 번째 초안을 거부한다.
*/
static String slugToColumn(String slug) {
return (slug == null || slug.isBlank()) ? null : slug;
}
/** 반대 방향. 계약은 slug가 null이 아니라 빈 문자열이어야 한다고 정한다. */
static String slugFromColumn(String slug) {
return slug == null ? "" : slug;
}
static String orEmpty(String value) {
return value == null ? "" : value;
}
/**
* 계약의 {@code format: date}를 {@code timestamptz} 컬럼에 담는다. 자정 UTC로 고정한다 — 저장 시각의 로컬 타임존을 쓰면 같은 날짜가
* 서버 위치에 따라 다른 순간이 되고, 다시 읽을 때 하루가 밀린다.
*/
static Timestamp dateToTimestamp(LocalDate date) {
return date == null ? null : Timestamp.from(date.atStartOfDay(ZoneOffset.UTC).toInstant());
}
static LocalDate dateFromTimestamp(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toInstant().atZone(ZoneOffset.UTC).toLocalDate();
}
static Instant instant(ResultSet rs, String column) throws SQLException {
Timestamp value = rs.getTimestamp(column);
return value == null ? null : value.toInstant();
}
}
@@ -0,0 +1,92 @@
-- Studio WorkingCopy 계약(studio-v1.yaml)이 요구하는 편집 필드를 네 source aggregate에 채운다.
--
-- 배경: V7(= 설계 패키지 database/V1__init.sql)의 물리 스키마는 유형마다 다른 모델을 갖는데,
-- Studio 계약은 네 유형을 공통 base(WorkingCopyInputBase) + 유형별 확장이라는 하나의 편집
-- 흐름으로 다룬다. 그 공통 base와 유형별 필드 중 V7에 대응 컬럼이 없는 것만 여기서 채운다.
-- 계약에 없는 V7 컬럼(environment_items, alternatives_markdown, freshness_status 등)은
-- 건드리지 않는다 — 설계 패키지의 정본 스키마이고 public-v1 구현이 쓸 수 있다.
--
-- ADR-003과 충돌하지 않는다. ADR-003이 금지한 것은 네 Aggregate를 하나의 범용
-- `working_copy` 테이블 + 통짜 JSON으로 뭉개는 것이다. 여기서는 각 Aggregate가 자기
-- 테이블을 그대로 소유한 채 필요한 컬럼만 얻는다.
-- ── 공통 base ───────────────────────────────────────────────────────────────
-- 계약 WorkingCopyInputBase.summary (maxLength 300). open_question.summary(varchar 600)과
-- project_decision(아래)에는 각각 대응 컬럼이 있거나 새로 만든다.
ALTER TABLE document ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
-- ── CASE ────────────────────────────────────────────────────────────────────
-- 계약의 problem/conclusion은 maxLength 100000인 본문이다. V7의 *_summary는 varchar(600)이라
-- 그대로 쓰면 잘린다 — text로 넓힌다(폭을 늘리는 변경이라 기존 행에 무손실).
ALTER TABLE case_detail ALTER COLUMN problem_summary TYPE text;
ALTER TABLE case_detail ALTER COLUMN conclusion_summary TYPE text;
-- environment_items(jsonb 배열)는 계약의 environment(단일 문자열)와 다른 모양이다.
-- 계약 쪽을 담을 컬럼을 따로 둔다. 기존 컬럼은 그대로 남긴다.
ALTER TABLE case_detail ADD COLUMN environment text NOT NULL DEFAULT '';
ALTER TABLE case_detail ADD COLUMN reproduction text NOT NULL DEFAULT '';
-- ── REFERENCE ───────────────────────────────────────────────────────────────
ALTER TABLE reference_detail ALTER COLUMN scope_summary TYPE text;
-- rules[] = ReferenceRule{id,title,body,order}, examples[] = OrderedText{id,text,order}.
-- applyWhen[]/exceptions[]는 기존 applies_to/excluded_scope를 그대로 쓴다.
ALTER TABLE reference_detail ADD COLUMN rules jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(rules) = 'array');
ALTER TABLE reference_detail ADD COLUMN examples jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(examples) = 'array');
-- ── QUESTION ────────────────────────────────────────────────────────────────
-- options[] = QuestionOption{id,title,description,order}.
-- facts/assumptions/unknowns/constraints는 기존 question_point(point_kind)로 간다.
ALTER TABLE open_question ADD COLUMN options jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(options) = 'array');
-- QuestionResolution{summary, evidenceTargetId, linkLabel} 중 summary만 V7에 있다.
ALTER TABLE open_question ADD COLUMN resolution_evidence_target_id uuid;
ALTER TABLE open_question ADD COLUMN resolution_link_label varchar(120) NOT NULL DEFAULT '';
-- ── PROJECT_DECISION ────────────────────────────────────────────────────────
-- 계약은 PROJECT_DECISION도 다른 셋과 같은 공통 base(title/slug/summary/topicId)로 편집한다.
-- V7의 project_decision에는 statement/rationale/consequences만 있다.
ALTER TABLE project_decision ADD COLUMN title varchar(120) NOT NULL DEFAULT '';
ALTER TABLE project_decision ADD COLUMN slug varchar(180);
ALTER TABLE project_decision ADD COLUMN summary varchar(300) NOT NULL DEFAULT '';
ALTER TABLE project_decision ADD COLUMN primary_topic_id uuid REFERENCES topic(id);
-- 계약 statement는 maxLength 100000이다. varchar(1000)이면 잘린다.
ALTER TABLE project_decision ALTER COLUMN statement TYPE text;
-- 계약은 PROJECT_DECISION 의 projectId 를 "게시 시점에 non-null, 저장 시점에는 강제하지 않음"으로
-- 정한다(studio-v1.yaml WorkingCopyInputBase.projectId). V7 의 NOT NULL 은 그 초안 저장을
-- 구조적으로 불가능하게 만든다 — 게시 필수 여부는 검증이 판단하도록 컬럼 제약을 푼다.
ALTER TABLE project_decision ALTER COLUMN project_id DROP NOT NULL;
ALTER TABLE project_decision ADD CONSTRAINT uq_project_decision_slug UNIQUE (slug);
ALTER TABLE project_decision ADD CONSTRAINT ck_project_decision_slug_non_blank
CHECK (slug IS NULL OR length(trim(slug)) > 0);
-- ── relations ───────────────────────────────────────────────────────────────
-- 계약의 relations[]는 네 유형 공통 base에 있고 항목이 {id, targetId, reason, order}다.
-- V7의 document_relation은 (source, target, relation_type) 복합 PK라 항목 자체의 id도
-- reason도 없고 document끼리만 성립한다 — QUESTION/PROJECT_DECISION의 relations를 담을 수
-- 없다. 그래서 Studio 편집용 관계는 전용 테이블에 둔다. document_relation은 설계의 공개
-- 렌더링용 유형 관계로 그대로 남는다.
--
-- (source_kind, source_id) 다형 참조는 이 스키마가 studio_validation/studio_preview에서
-- 이미 쓰는 방식과 같다. 다형 참조라 FK를 걸 수 없으므로 부모 삭제 시 정리는 application이
-- 책임진다.
--
-- target_id가 nullable인 것은 계약(RelationInput.targetId: [string,"null"])을 따른 것이다 —
-- 아직 대상을 고르지 않은 관계 줄도 저장할 수 있어야 한다. 게시 가능 여부는 검증이 판단한다.
CREATE TABLE studio_relation (
id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
target_id uuid,
reason text NOT NULL DEFAULT '',
display_order integer NOT NULL CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_studio_relation_order UNIQUE (source_kind, source_id, display_order),
CONSTRAINT ck_studio_relation_not_self CHECK (target_id IS NULL OR target_id <> source_id)
);
CREATE INDEX idx_studio_relation_source ON studio_relation (source_kind, source_id);
CREATE INDEX idx_studio_relation_target ON studio_relation (target_id)
WHERE target_id IS NOT NULL;
@@ -0,0 +1,716 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.studio;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcPreviewArtifactAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.artifact.JdbcValidationArtifactAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcDependencyRevisionAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDashboardQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDependencyResolverAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcStudioDocumentQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.asset.JdbcAssetRepositoryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationHistoryQueryAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.studio.publication.JdbcPublicationWriterAdapter;
import dev.caskeleton.adapter.outbound.persistence.techlog.workingcopy.JdbcWorkingCopyRepositoryAdapter;
import dev.caskeleton.application.techlog.studio.model.AssetKindView;
import dev.caskeleton.application.techlog.studio.model.AssetManagementStatusView;
import dev.caskeleton.application.techlog.studio.model.AssetView;
import dev.caskeleton.application.techlog.studio.model.DecisionStatusView;
import dev.caskeleton.application.techlog.studio.model.DocumentSort;
import dev.caskeleton.application.techlog.studio.model.NextAction;
import dev.caskeleton.application.techlog.studio.model.OrderedTextView;
import dev.caskeleton.application.techlog.studio.model.PublicPreviewView;
import dev.caskeleton.application.techlog.studio.model.PublicationAggregateStatus;
import dev.caskeleton.application.techlog.studio.model.PublishResultView;
import dev.caskeleton.application.techlog.studio.model.QuestionStatusView;
import dev.caskeleton.application.techlog.studio.model.RecordKind;
import dev.caskeleton.application.techlog.studio.model.ReferenceRuleView;
import dev.caskeleton.application.techlog.studio.model.RelationView;
import dev.caskeleton.application.techlog.studio.model.ValidationReportView;
import dev.caskeleton.application.techlog.studio.model.ValidationStatus;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyBaseInput;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyInputView;
import dev.caskeleton.application.techlog.studio.model.WorkingCopyView;
import dev.caskeleton.application.techlog.studio.port.out.AssetRepositoryPort;
import dev.caskeleton.application.techlog.studio.port.out.PublicationWriterPort;
import dev.caskeleton.application.techlog.studio.port.out.StudioDependencyResolverPort;
import dev.caskeleton.application.techlog.studio.query.ListAssetsQuery;
import dev.caskeleton.application.techlog.studio.query.ListDocumentsQuery;
import dev.caskeleton.application.techlog.studio.query.ListPublicationsQuery;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/**
* Studio 영속 경로 전체를 실제 PostgreSQL 위에서 돌린다.
*
* <p>이 테스트가 필요한 이유는 분명하다 — 이 저장소의 표준 {@code check} 는 Testcontainers 통합 테스트를 돌리지 않으므로, 여기 있는 SQL 은 이
* 테스트 없이는 <b>한 번도 실행되지 않은 채</b> 통과한다. 컴파일과 단위 테스트는 컬럼 이름 오타도, jsonb 캐스팅 누락도, 순환 FK 의 지연 검사도 검증하지
* 못한다.
*
* <p>{@code JdbcCatalogQueryAdapterTest} 와 같은 형제 패턴을 쓴다 — 이 모듈에는 {@code @SpringBootConfiguration} 이
* 없으므로 Testcontainers + 순수 Flyway + 직접 조립이다.
*/
class StudioPersistenceIntegrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcWorkingCopyRepositoryAdapter workingCopies;
private static JdbcStudioDocumentQueryAdapter documents;
private static JdbcStudioDependencyResolverAdapter dependencies;
private static JdbcDependencyRevisionAdapter dependencyRevisions;
private static JdbcValidationArtifactAdapter validations;
private static JdbcPreviewArtifactAdapter previews;
private static JdbcPublicationWriterAdapter publicationWriter;
private static JdbcPublicationHistoryQueryAdapter publicationHistory;
private static JdbcStudioDashboardQueryAdapter dashboard;
private static JdbcAssetRepositoryAdapter assets;
private static org.springframework.transaction.support.TransactionTemplate transactions;
private static UUID topicId;
private static UUID projectId;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the Studio persistence integration test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
ObjectMapper objectMapper = new ObjectMapper();
workingCopies = new JdbcWorkingCopyRepositoryAdapter(jdbcClient, objectMapper);
documents = new JdbcStudioDocumentQueryAdapter(jdbcClient);
dependencies = new JdbcStudioDependencyResolverAdapter(jdbcClient);
dependencyRevisions = new JdbcDependencyRevisionAdapter(jdbcClient);
validations = new JdbcValidationArtifactAdapter(jdbcClient, objectMapper);
previews = new JdbcPreviewArtifactAdapter(jdbcClient);
publicationWriter = new JdbcPublicationWriterAdapter(jdbcClient, objectMapper);
publicationHistory = new JdbcPublicationHistoryQueryAdapter(jdbcClient);
dashboard = new JdbcStudioDashboardQueryAdapter(jdbcClient);
assets = new JdbcAssetRepositoryAdapter(jdbcClient);
// 게시는 프로덕션에서 TransactionPort.inWrite 안에서 돈다. 순환 FK 의 지연 검사가 성립하려면
// 트랜잭션이 반드시 있어야 하므로 테스트도 같은 조건에서 호출한다.
transactions =
new org.springframework.transaction.support.TransactionTemplate(
new org.springframework.jdbc.support.JdbcTransactionManager(dataSource));
topicId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by)"
+ " VALUES (:id, 'Kafka', 'kafka', 'kafka', 'test', 'test')")
.param("id", topicId)
.update();
projectId = UUID.randomUUID();
jdbcClient
.sql(
"INSERT INTO project (id, slug, name, created_by, updated_by)"
+ " VALUES (:id, 'tech-log', 'Tech Log', 'test', 'test')")
.param("id", projectId)
.update();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
private static WorkingCopyBaseInput base(RecordKind kind, String title, String slug) {
return new WorkingCopyBaseInput(kind, title, slug, "요약", topicId, projectId, List.of());
}
@Test
void v8AddsEveryColumnTheStudioContractNeeds() {
List<String> caseColumns = columnsOf("case_detail");
assertThat(caseColumns).contains("environment", "reproduction");
assertThat(columnsOf("reference_detail")).contains("rules", "examples");
assertThat(columnsOf("open_question"))
.contains("options", "resolution_evidence_target_id", "resolution_link_label");
assertThat(columnsOf("project_decision"))
.contains("title", "slug", "summary", "primary_topic_id");
assertThat(columnsOf("document")).contains("summary");
assertThat(columnsOf("studio_relation")).contains("source_kind", "source_id", "target_id");
// 계약이 허용한 "프로젝트 없는 결정 초안" 저장이 가능해야 한다.
assertThat(isNullable("project_decision", "project_id")).isTrue();
// 계약의 본문 길이는 100000 이라 varchar(600)/varchar(1000) 로는 담을 수 없다.
assertThat(typeOf("case_detail", "problem_summary")).isEqualTo("text");
assertThat(typeOf("project_decision", "statement")).isEqualTo("text");
}
@Test
void aCaseWorkingCopyRoundTripsThroughEveryColumnItTouches() {
WorkingCopyView created =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "장애 사례", "outage-case"),
"문제",
"결론",
"환경",
"재현",
LocalDate.of(2026, 8, 1),
"본문 :::evidence key=\"x\""),
"tester");
assertThat(created.version()).as("계약의 version 은 minimum 1 이다").isEqualTo(1L);
WorkingCopyView loaded = workingCopies.find(created.id()).orElseThrow();
assertThat(loaded).isInstanceOf(WorkingCopyView.CaseWorkingCopyView.class);
WorkingCopyView.CaseWorkingCopyView value = (WorkingCopyView.CaseWorkingCopyView) loaded;
assertThat(value.problem()).isEqualTo("문제");
assertThat(value.reproduction()).isEqualTo("재현");
assertThat(value.lastVerifiedOn()).isEqualTo(LocalDate.of(2026, 8, 1));
assertThat(value.base().topicId()).isEqualTo(topicId);
assertThat(value.base().projectId()).as("프로젝트 링크 테이블 왕복").isEqualTo(projectId);
assertThat(workingCopies.findKind(created.id())).contains(RecordKind.CASE);
}
@Test
void savingWithTheWrongVersionChangesNothing() {
WorkingCopyView created =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "낙관적 잠금", "optimistic-lock"), "", "", "", "", null, ""),
"tester");
Optional<WorkingCopyView> conflict =
workingCopies.save(
created.id(),
created.version() + 99,
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "덮어쓰기 시도", "optimistic-lock"), "", "", "", "", null, ""),
"tester");
assertThat(conflict).isEmpty();
assertThat(workingCopies.find(created.id()).orElseThrow().base().title()).isEqualTo("낙관적 잠금");
WorkingCopyView saved =
workingCopies
.save(
created.id(),
created.version(),
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "정상 저장", "optimistic-lock"), "", "", "", "", null, ""),
"tester")
.orElseThrow();
assertThat(saved.version()).isEqualTo(created.version() + 1);
assertThat(saved.base().title()).isEqualTo("정상 저장");
}
@Test
void theOtherThreeKindsRoundTripThroughTheirOwnTables() {
WorkingCopyView reference =
workingCopies.create(
new WorkingCopyInputView.ReferenceInputView(
base(RecordKind.REFERENCE, "기준 문서", "reference-doc"),
"목적",
List.of(new ReferenceRuleView(UUID.randomUUID(), "규칙", "본문", 0)),
List.of(new OrderedTextView(UUID.randomUUID(), "적용", 0)),
List.of(),
List.of(new OrderedTextView(UUID.randomUUID(), "예시", 0)),
LocalDate.of(2026, 7, 1)),
"tester");
WorkingCopyView.ReferenceWorkingCopyView loadedReference =
(WorkingCopyView.ReferenceWorkingCopyView) workingCopies.find(reference.id()).orElseThrow();
assertThat(loadedReference.rules())
.singleElement()
.extracting(ReferenceRuleView::title)
.isEqualTo("규칙");
assertThat(loadedReference.examples())
.singleElement()
.extracting(OrderedTextView::text)
.isEqualTo("예시");
WorkingCopyView question =
workingCopies.create(
new WorkingCopyInputView.QuestionInputView(
base(RecordKind.QUESTION, "미해결 질문", "open-question"),
QuestionStatusView.OPEN,
List.of(new OrderedTextView(UUID.randomUUID(), "사실", 0)),
List.of(),
List.of(new OrderedTextView(UUID.randomUUID(), "모르는 것", 0)),
List.of(),
List.of(),
"다음 검증",
null),
"tester");
WorkingCopyView.QuestionWorkingCopyView loadedQuestion =
(WorkingCopyView.QuestionWorkingCopyView) workingCopies.find(question.id()).orElseThrow();
assertThat(loadedQuestion.facts())
.singleElement()
.extracting(OrderedTextView::text)
.isEqualTo("사실");
assertThat(loadedQuestion.unknowns()).hasSize(1);
assertThat(loadedQuestion.nextValidation()).isEqualTo("다음 검증");
assertThat(loadedQuestion.base().projectId()).isEqualTo(projectId);
WorkingCopyView decision =
workingCopies.create(
new WorkingCopyInputView.ProjectDecisionInputView(
base(RecordKind.PROJECT_DECISION, "결정", "a-decision"),
DecisionStatusView.ADOPTED,
LocalDate.of(2026, 6, 1),
"결정문",
"근거",
List.of(new OrderedTextView(UUID.randomUUID(), "결과", 0))),
"tester");
WorkingCopyView.ProjectDecisionWorkingCopyView loadedDecision =
(WorkingCopyView.ProjectDecisionWorkingCopyView)
workingCopies.find(decision.id()).orElseThrow();
// 계약의 ADOPTED 는 Domain 의 ACCEPTED 다(ADR-003).
assertThat(loadedDecision.decisionStatus()).isEqualTo(DecisionStatusView.ADOPTED);
assertThat(storedDecisionStatus(decision.id())).isEqualTo("ACCEPTED");
assertThat(loadedDecision.consequences()).hasSize(1);
}
@Test
void relationsSurviveAReplaceAndKeepTheirIdentity() {
WorkingCopyView target =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "관계 대상", "relation-target"), "", "", "", "", null, ""),
"tester");
RelationView relation = new RelationView(null, target.id(), "왜 관련 있는지", 0);
WorkingCopyView source =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
new WorkingCopyBaseInput(
RecordKind.CASE,
"관계 원본",
"relation-source",
"요약",
topicId,
projectId,
List.of(relation)),
"",
"",
"",
"",
null,
""),
"tester");
List<RelationView> stored = workingCopies.find(source.id()).orElseThrow().base().relations();
assertThat(stored).singleElement().extracting(RelationView::reason).isEqualTo("왜 관련 있는지");
UUID relationId = stored.getFirst().id();
assertThat(relationId).isNotNull();
WorkingCopyView saved =
workingCopies
.save(
source.id(),
source.version(),
new WorkingCopyInputView.CaseInputView(
new WorkingCopyBaseInput(
RecordKind.CASE,
"관계 원본",
"relation-source",
"요약",
topicId,
projectId,
List.of(new RelationView(relationId, target.id(), "이유 수정", 0))),
"",
"",
"",
"",
null,
""),
"tester")
.orElseThrow();
List<RelationView> afterSave = saved.base().relations();
assertThat(afterSave).singleElement().extracting(RelationView::reason).isEqualTo("이유 수정");
assertThat(afterSave.getFirst().id()).as("이 문서가 소유하던 관계 id 는 저장해도 유지된다").isEqualTo(relationId);
}
@Test
void theUnionQueryComputesDependencyRevisionAndNextAction() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "목록 대상", "list-target"), "", "", "", "", null, ""),
"tester");
var page =
documents.list(
new ListDocumentsQuery(
"목록", RecordKind.CASE, null, null, projectId, DocumentSort.UPDATED_DESC, null, 20));
assertThat(page.items()).extracting(item -> item.id()).contains(document.id());
assertThat(page.items().getFirst().nextAction())
.as("검증한 적이 없으면 다음 행동은 VALIDATE 다")
.isEqualTo(NextAction.VALIDATE);
String revision = dependencyRevisions.revisionFor(RecordKind.CASE, document.id());
assertThat(revision).isNotBlank();
assertThat(dependencyRevisions.revisionFor(RecordKind.CASE, document.id()))
.as("같은 상태면 같은 값이어야 한다")
.isEqualTo(revision);
// 제목이 비면 검증할 의미가 없으므로 CONTINUE_EDITING 이다.
workingCopies.save(
document.id(),
document.version(),
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "", "list-target"), "", "", "", "", null, ""),
"tester");
var blankTitlePage =
documents.list(
new ListDocumentsQuery(
null,
RecordKind.CASE,
null,
NextAction.CONTINUE_EDITING,
projectId,
DocumentSort.UPDATED_DESC,
null,
20));
assertThat(blankTitlePage.items()).extracting(item -> item.id()).contains(document.id());
}
@Test
void dependencyResolutionFindsTopicProjectRelationsAndSlugOwners() {
WorkingCopyView first =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "슬러그 주인", "shared-slug"), "", "", "", "", null, ""),
"tester");
WorkingCopyView second =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "슬러그 경쟁", ""), "", "", "", "", null, ""),
"tester");
StudioDependencyResolverPort.Resolved resolved =
dependencies.resolve(workingCopies.find(first.id()).orElseThrow(), Set.of());
assertThat(resolved.topic()).isNotNull();
assertThat(resolved.topic().publicPath()).isEqualTo("/topics/kafka");
assertThat(resolved.project().publicPath()).isEqualTo("/projects/tech-log");
assertThat(resolved.publicPath()).isEqualTo("/cases/shared-slug");
assertThat(resolved.topicMissing()).isFalse();
assertThat(resolved.slugOwnerId()).as("자기 자신은 slug 충돌이 아니다").isNull();
// 빈 slug 는 NULL 로 저장되므로 UNIQUE 제약을 여러 초안이 함께 지날 수 있다.
assertThat(workingCopies.find(second.id()).orElseThrow().base().slug()).isEmpty();
}
@Test
void validationAndPreviewArtifactsPersistAndReadBack() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "artifact", "artifact-case"), "문제", "결론", "", "", null, ""),
"tester");
Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);
ValidationReportView report =
new ValidationReportView(
UUID.randomUUID(),
document.id(),
document.version(),
ValidationStatus.WARNINGS,
List.of(
new dev.caskeleton.application.techlog.studio.model.ValidationIssueView(
"BODY_EMPTY",
dev.caskeleton.application.techlog.studio.model.ValidationSeverity.WARNING,
"/bodyMarkdown",
"본문이 비어 있다")),
now,
now.plusSeconds(3600),
"rev-1");
validations.save(RecordKind.CASE, report, "tester");
ValidationReportView loaded = validations.findById(report.validationId()).orElseThrow();
assertThat(loaded.status()).isEqualTo(ValidationStatus.WARNINGS);
assertThat(loaded.issues())
.singleElement()
.extracting(issue -> issue.code())
.isEqualTo("BODY_EMPTY");
assertThat(validations.latestFor(RecordKind.CASE, document.id())).isPresent();
PublicPreviewView preview =
new PublicPreviewView(
UUID.randomUUID(),
document.id(),
document.version(),
report.validationId(),
"rev-1",
now,
now.plusSeconds(3600),
"{\"kind\":\"CASE\"}");
previews.save(RecordKind.CASE, preview, "tester");
// jsonb 컬럼은 PostgreSQL 이 정규화해 되돌려준다(공백·키 순서가 입력과 같지 않다). 계약 DTO 로
// 다시 파싱해 쓰는 값이라 의미는 보존되지만, 바이트 동일성을 기대하면 안 된다.
String storedRenderModel =
previews.findById(preview.previewId()).orElseThrow().renderModelJson();
assertThat(new ObjectMapper().readTree(storedRenderModel).path("kind").asString(""))
.isEqualTo("CASE");
}
@Test
void publishWritesTheWholeAggregateAndUnpublishWithdrawsIt() {
WorkingCopyView document =
workingCopies.create(
new WorkingCopyInputView.CaseInputView(
base(RecordKind.CASE, "게시 대상", "publish-target"), "문제", "결론", "", "", null, "본문"),
"tester");
PublishResultView published =
transactions.execute(
status ->
publicationWriter.publish(
new PublicationWriterPort.PublishRequest(
RecordKind.CASE,
document.id(),
document.version(),
"게시 대상",
"요약",
topicId,
projectId,
"/cases/publish-target",
null,
"{\"kind\":\"CASE\"}",
"본문 평문",
List.of(),
"1",
"1",
"idem-1",
"tester")));
assertThat(published.publication().status()).isEqualTo(PublicationAggregateStatus.PUBLISHED);
assertThat(published.publication().publicationRevision())
.as("첫 게시의 revision 은 1 이다 — INSERT 가 넣은 값을 UPDATE 가 또 올리면 안 된다")
.isEqualTo(1L);
assertThat(published.event().snapshotAvailable()).isTrue();
// 순환 FK(publication.latest_event_id <-> publication_event)가 지연 검사로 통과해야 한다.
assertThat(published.publication().latestEventId())
.isEqualTo(published.event().publicationEventId());
assertThat(count("public_resource_projection", "resource_id", document.id())).isEqualTo(1);
assertThat(count("public_route", "resource_id", document.id())).isEqualTo(1);
assertThat(
jdbcClient
.sql("SELECT workflow_status FROM document WHERE id = :id")
.param("id", document.id())
.query(String.class)
.single())
.isEqualTo("PUBLISHED");
// 재게시는 REPUBLISHED 이벤트와 revision 증가를 만든다.
PublishResultView republished =
transactions.execute(
status ->
publicationWriter.publish(
new PublicationWriterPort.PublishRequest(
RecordKind.CASE,
document.id(),
document.version(),
"게시 대상",
"요약",
topicId,
projectId,
"/cases/publish-target",
null,
"{\"kind\":\"CASE\"}",
"본문 평문",
List.of(),
"1",
"1",
"idem-2",
"tester")));
assertThat(republished.event().type())
.isEqualTo(
dev.caskeleton.application.techlog.studio.model.PublicationEventTypeView.REPUBLISHED);
assertThat(republished.publication().publicationRevision()).isEqualTo(2L);
var page = publicationHistory.list(new ListPublicationsQuery(null, null, null, 20));
assertThat(page.items()).isNotEmpty();
assertThat(page.items().getFirst().document()).isNotNull();
assertThat(publicationHistory.findSnapshot(published.event().publicationEventId())).isPresent();
PublishResultView withdrawn =
transactions.execute(
status ->
publicationWriter.unpublish(
new PublicationWriterPort.UnpublishRequest(
published.publication().publicationId(),
RecordKind.CASE,
document.id(),
republished.publication().publicationRevision(),
"tester")));
assertThat(withdrawn.publication().status()).isEqualTo(PublicationAggregateStatus.UNPUBLISHED);
assertThat(withdrawn.event().sourcePublishedEventId())
.as("UNPUBLISHED 이벤트는 마지막 공개 Snapshot 을 반드시 참조한다")
.isNotNull();
assertThat(
jdbcClient
.sql(
"SELECT publication_state FROM public_resource_projection"
+ " WHERE resource_id = :id")
.param("id", document.id())
.query(String.class)
.single())
.isEqualTo("WITHDRAWN");
assertThat(count("public_route", "resource_id", document.id()))
.as("게시 취소는 주소를 지우지 않는다 — 지우면 공개된 링크가 끊긴다")
.isEqualTo(1);
}
@Test
void dashboardCountsUseTheSameProjectionAsTheList() {
var totals = dashboard.totals();
assertThat(totals.documents()).isPositive();
assertThat(dashboard.topByNextAction(List.of(NextAction.VALIDATE), 5)).isNotNull();
}
@Test
void assetsRoundTripAndReportTheirUsage() {
UUID assetId = UUID.randomUUID();
AssetView created =
assets.create(
new AssetRepositoryPort.NewAsset(
assetId,
"diagram-key",
AssetKindView.DIAGRAM,
"image/png",
"techlog/assets/" + assetId,
"diagram.png",
1024L,
800,
600,
"a".repeat(64),
"설명",
false,
AssetManagementStatusView.READY),
"tester");
assertThat(created.version()).as("계약의 Asset.version 은 minimum 1 이다").isEqualTo(1L);
assertThat(created.publicPath()).isEqualTo("/media/" + assetId);
assertThat(created.usageCount()).isZero();
var page =
assets.list(new ListAssetsQuery("diagram", AssetKindView.DIAGRAM, null, null, null, 20));
assertThat(page.items()).extracting(AssetView::id).contains(assetId);
AssetView updated =
assets
.update(
assetId,
created.version(),
null,
null,
true,
Boolean.TRUE,
AssetManagementStatusView.ARCHIVED,
"tester")
.orElseThrow();
assertThat(updated.altText()).as("altTextProvided=true 는 null 로 지우는 것을 뜻한다").isNull();
assertThat(updated.decorative()).isTrue();
assertThat(updated.managementStatus()).isEqualTo(AssetManagementStatusView.ARCHIVED);
assertThat(updated.version()).isEqualTo(2L);
assertThat(assets.update(assetId, 99L, null, null, false, null, null, "tester")).isEmpty();
assertThat(assets.findObjectKey(assetId)).contains("techlog/assets/" + assetId);
var detail = assets.findDetail(assetId).orElseThrow();
assertThat(detail.hasPublicationHistory()).isFalse();
assertThat(detail.usages()).isEmpty();
assets.delete(assetId);
assertThat(assets.find(assetId)).isEmpty();
}
private static List<String> columnsOf(String table) {
return jdbcClient
.sql("SELECT column_name FROM information_schema.columns WHERE table_name = :table")
.param("table", table)
.query(String.class)
.list();
}
private static boolean isNullable(String table, String column) {
return "YES"
.equals(
jdbcClient
.sql(
"SELECT is_nullable FROM information_schema.columns"
+ " WHERE table_name = :table AND column_name = :column")
.param("table", table)
.param("column", column)
.query(String.class)
.single());
}
private static String typeOf(String table, String column) {
return jdbcClient
.sql(
"SELECT data_type FROM information_schema.columns"
+ " WHERE table_name = :table AND column_name = :column")
.param("table", table)
.param("column", column)
.query(String.class)
.single();
}
private static String storedDecisionStatus(UUID id) {
return jdbcClient
.sql("SELECT decision_status FROM project_decision WHERE id = :id")
.param("id", id)
.query(String.class)
.single();
}
private static int count(String table, String column, UUID id) {
return jdbcClient
.sql("SELECT count(*) FROM " + table + " WHERE " + column + " = :id")
.param("id", id)
.query(Integer.class)
.single();
}
}