feat: 프로젝트 게시와 홈 focus, 그리고 기록 사이 연결을 실제로 가능하게 한다

계약에 선언만 되어 있고 구현이 없던 네 오퍼레이션과, 의도된 스텁으로 남아 있던
catalog 두 종류가 공개 화면 다섯 곳을 조용히 비워 두고 있었다.

프로젝트 게시 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지 못하는데,
공개 조회들(프로젝트 목록·상세·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을 세우는
경로가 없었으므로 프로젝트는 만들 수는 있어도 공개될 수는 없었다. 릴리스가 자체 경로를
갖는 것과 같은 이유로, 문서 파이프라인에 끼워 넣지 않고 원본 상태·투영·canonical
route 세 곳을 한 트랜잭션에서 함께 세운다.

홈 focus — `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을
읽고 쓸 use case 가 없었다. 지목한 대상이 실제로 있는지는 여기서 확인한다. 테이블에
FK 가 없어(설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 도 저장되고,
그러면 공개 화면은 조용히 빈 focus 를 그린다 — 저장은 성공했는데 아무것도 안 나오는,
이유를 알 수 없는 실패가 된다. 대상이 공개인지는 확인하지 않는다: 미리 지목해 두고
게시와 동시에 뜨게 하는 것이 정상적인 순서다.

catalog RELATION/EVIDENCE — 「슬라이스 2·5에서 채운다」는 주석과 함께 `List.of()` 로
남아 있었다. 그래서 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은 작성
중에 고르는 것이라 작업본까지 포함하고(두 문서를 같이 쓰면서 서로 잇는 것이 정상적인
순서다), EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다.

계약은 이 네 오퍼레이션을 ProblemDetails 모양으로 두고 있었다. 백엔드가 모든 JSON
응답을 envelope 으로 감싸므로 구현하는 순간 어긋난다 — 나머지와 같은 모양으로 옮겼다.
`/home-focus` 는 케밥 세그먼트라 D19(AIP-122)를 위반해 `/home/focus` 로 나눴다.

함께 고친 것들 (모두 이 작업 전부터 빨간 상태였다):
- `error-codes.yaml` 의 DOCUMENT_NOT_FOUND 가 표면마다 하나씩 두 행이었다. 이
  레지스트리의 식별자는 code 하나뿐이라 로딩 자체가 깨졌고, 그 여파로 거버넌스 테스트
  네 개와 outbox 계약 테스트가 함께 넘어졌다. 같은 코드는 같은 말을 해야 한다.
- 벤더된 계약 세 개의 MANIFEST.sha256 이 실제 파일과 어긋나 있었다.
- ActuatorSecurityHttpTest 는 "DB·Redis 없는 슬라이스"라고 적어 두고 Redis 자동설정을
  막지 않아, localhost:6379 연결 실패가 /actuator/health 를 503 으로 만들었다. 보안
  태세와 무관한 이유로 빨개지던 테스트다.
- 테스트 JVM 힙이 Gradle 기본 512m 이라 Spring context 캐시 + ArchUnit +
  Testcontainers 조합에서 OOM 이 났다. 증상이 테스트 실패가 아니라 "Executor 를 완료할
  수 없음"이어서 원인을 가리켰다.
- APP_SESSION_TIMEOUT 이 env-keys 레지스트리에 없었다.

새 SQL 은 실제 PostgreSQL 위에서 돌린다 — 컴파일도 단위 테스트도 컬럼 이름을 검증하지
못한다는 것이 이 파일이 존재하는 이유다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-23 18:19:07 +09:00
co-authored by Claude Opus 5
parent 23d82bd250
commit 561d02ae3a
30 changed files with 1210 additions and 105 deletions
+16
View File
@@ -112,6 +112,22 @@ env_keys:
compatibility_impact: behavior-change compatibility_impact: behavior-change
required_test: env-contract:server-shutdown-timeout-aligned required_test: env-contract:server-shutdown-timeout-aligned
- name: APP_SESSION_TIMEOUT
# source: Studio 작성 세션. Spring 기본 30분 유휴 만료는 한 기록을 여러 번 저장하며
# 오래 머무는 작성 리듬보다 짧아, 저장하지 않은 편집을 잃는 원인이 되었다.
# 늘릴수록 훔친 session cookie 가 유효한 창도 같이 늘어나므로 Keycloak realm 의
# SSO idle 과 함께 맞춘다.
type: duration
default: 8h
allowed_values: null
classification: public-config
required: false
reload_policy: restart-only
owner_branch: feature-env-driven-runtime-configuration
validation: spring_duration_shorthand
compatibility_impact: behavior-change
required_test: env-contract:session-timeout
- name: APP_SERVER_FORWARD_HEADERS_STRATEGY - name: APP_SERVER_FORWARD_HEADERS_STRATEGY
# source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification # source: feature-env-driven-runtime-configuration D2 (2026-06-05) — APP_ unification
# (trust X-Forwarded-* when behind LB/proxy) # (trust X-Forwarded-* when behind LB/proxy)
+6 -14
View File
@@ -960,7 +960,12 @@ errors:
compatibility_impact: additive compatibility_impact: additive
required_test: StudioErrorTest required_test: StudioErrorTest
# source: studio-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (StudioError.DOCUMENT_NOT_FOUND) # source: studio-v1.yaml + studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND
# (StudioError.DOCUMENT_NOT_FOUND, ManagementError.DOCUMENT_NOT_FOUND)
#
# 두 표면이 같은 코드를 쓴다. 이 레지스트리의 식별자는 code 하나뿐이므로 행도 하나다 —
# 한때 표면마다 행을 두고 문구를 다르게 적었고("작업본을 찾을 수 없습니다"), 그 중복이
# 레지스트리 로딩 자체를 깨뜨렸다. 같은 코드는 같은 말을 해야 한다.
- code: DOCUMENT_NOT_FOUND - code: DOCUMENT_NOT_FOUND
category: NOT_FOUND category: NOT_FOUND
http_status: 404 http_status: 404
@@ -1403,19 +1408,6 @@ errors:
runbook_link: null runbook_link: null
compatibility_impact: additive compatibility_impact: additive
required_test: ManagementErrorRegistryTest required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_NOT_FOUND (ManagementError.DOCUMENT_NOT_FOUND)
- code: DOCUMENT_NOT_FOUND
category: NOT_FOUND
http_status: 404
retryable: false
retry_after_seconds: null
owner_branch: feature-techlog-management-v1
owner_layer: application
client_safe_message: "작업본을 찾을 수 없습니다"
log_level: INFO
runbook_link: null
compatibility_impact: additive
required_test: ManagementErrorRegistryTest
# source: studio-management-v1.yaml ApiError.code — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED) # source: studio-management-v1.yaml ApiError.code — DOCUMENT_PUBLISHED (ManagementError.DOCUMENT_PUBLISHED)
- code: DOCUMENT_PUBLISHED - code: DOCUMENT_PUBLISHED
category: CONFLICT category: CONFLICT
@@ -26,7 +26,7 @@ public final class ManagementClientSafeMessages {
case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다"; case RELEASE_NOT_FOUND -> "요청한 릴리즈를 찾을 수 없습니다";
case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다"; case RELEASE_VERSION_TAKEN -> "같은 버전의 릴리즈가 이미 있습니다";
case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다"; case RELEASE_NOT_PUBLISHABLE -> "지금 상태에서는 발행할 수 없습니다";
case DOCUMENT_NOT_FOUND -> "작업본을 찾을 수 없습니다"; case DOCUMENT_NOT_FOUND -> "요청한 문서를 찾을 수 없습니다";
case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요"; case DOCUMENT_PUBLISHED -> "공개된 기록은 삭제할 수 없습니다. 먼저 공개를 취소해 주세요";
case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다"; case DOCUMENT_IN_USE -> "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다";
case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다"; case QUESTION_NOT_FOUND -> "질문을 찾을 수 없습니다";
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.controller;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal;
import dev.caskeleton.adapter.inbound.web.techlog.management.ManagementPrincipals;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/**
* 홈 focus 설정. 계약 {@code getHomeFocus}/{@code updateHomeFocus}.
*
* <p>단일 행이라 경로에 id 가 없다. 낙관적 잠금은 본문의 {@code expectedVersion} 으로만 한다.
*/
@RestController
public class ManagementHomeFocusController {
private final GetHomeFocusUseCase getHomeFocus;
private final UpdateHomeFocusUseCase updateHomeFocus;
public ManagementHomeFocusController(
GetHomeFocusUseCase getHomeFocus, UpdateHomeFocusUseCase updateHomeFocus) {
this.getHomeFocus = getHomeFocus;
this.updateHomeFocus = updateHomeFocus;
}
@GetMapping("/v1/studio/home/focus")
public HomeFocusResponse getHomeFocus() {
return ManagementResponseMapper.homeFocus(getHomeFocus.handle());
}
@PutMapping("/v1/studio/home/focus")
public HomeFocusResponse updateHomeFocus(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@RequestBody HomeFocusRequest body) {
return ManagementResponseMapper.homeFocus(
updateHomeFocus.handle(
new UpdateHomeFocusCommand(
body.getExpectedVersion(),
body.getDefaultType() == null ? null : body.getDefaultType().getValue(),
body.getCurrentProjectId(),
body.getOpenQuestionId(),
body.getRecentDecisionId(),
ManagementPrincipals.require(principal))));
}
}
@@ -8,6 +8,8 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ExpectedV
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectUpdateRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishRequest;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PublishResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper; import dev.caskeleton.adapter.inbound.web.techlog.management.mapper.ManagementResponseMapper;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand; import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand; import dev.caskeleton.application.techlog.management.command.DeleteProjectCommand;
@@ -16,6 +18,8 @@ import dev.caskeleton.application.techlog.management.service.CreateProjectUseCas
import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase; import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCase;
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase; import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase; import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -41,17 +45,24 @@ public class ManagementProjectController {
private final UpdateProjectUseCase updateProject; private final UpdateProjectUseCase updateProject;
private final DeleteProjectUseCase deleteProject; private final DeleteProjectUseCase deleteProject;
private final PublishProjectUseCase publishProject;
private final UnpublishProjectUseCase unpublishProject;
public ManagementProjectController( public ManagementProjectController(
ListStudioProjectsUseCase listProjects, ListStudioProjectsUseCase listProjects,
GetProjectForEditUseCase getProject, GetProjectForEditUseCase getProject,
CreateProjectUseCase createProject, CreateProjectUseCase createProject,
UpdateProjectUseCase updateProject, UpdateProjectUseCase updateProject,
DeleteProjectUseCase deleteProject) { DeleteProjectUseCase deleteProject,
PublishProjectUseCase publishProject,
UnpublishProjectUseCase unpublishProject) {
this.listProjects = listProjects; this.listProjects = listProjects;
this.getProject = getProject; this.getProject = getProject;
this.createProject = createProject; this.createProject = createProject;
this.updateProject = updateProject; this.updateProject = updateProject;
this.deleteProject = deleteProject; this.deleteProject = deleteProject;
this.publishProject = publishProject;
this.unpublishProject = unpublishProject;
} }
@GetMapping("/v1/studio/projects") @GetMapping("/v1/studio/projects")
@@ -113,4 +124,27 @@ public class ManagementProjectController {
new DeleteProjectCommand( new DeleteProjectCommand(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal))); id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
} }
@PostMapping("/v1/studio/projects/{id}/publish")
public PublishResponse publishProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody PublishRequest body) {
return ManagementResponseMapper.projectPublication(
publishProject.handle(
id,
body.getExpectedVersion(),
body.getVisibility() == null ? null : body.getVisibility().getValue(),
ManagementPrincipals.require(principal)));
}
@PostMapping("/v1/studio/projects/{id}/unpublish")
public ProjectEditResponse unpublishProject(
@AuthenticationPrincipal AuthenticatedPrincipal principal,
@PathVariable("id") UUID id,
@RequestBody ExpectedVersionRequest body) {
return ManagementResponseMapper.project(
unpublishProject.handle(
id, body.getExpectedVersion(), ManagementPrincipals.require(principal)));
}
} }
@@ -1,6 +1,7 @@
package dev.caskeleton.adapter.inbound.web.techlog.management.mapper; package dev.caskeleton.adapter.inbound.web.techlog.management.mapper;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.CreateDraftResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.HomeFocusResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.PageMetadata;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectEditResponse;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ProjectIndexItem;
@@ -11,6 +12,7 @@ import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseEd
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexItem;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.ReleaseIndexPage;
import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit; import dev.caskeleton.adapter.inbound.web.techlog.management.api.model.TopicEdit;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView; import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView; import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.model.ReleaseEditView; import dev.caskeleton.application.techlog.management.model.ReleaseEditView;
@@ -188,6 +190,29 @@ public final class ManagementResponseMapper {
pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages())); pageMetadata(page.number(), page.size(), page.totalElements(), page.totalPages()));
} }
/** 홈 focus 설정. 세 슬롯은 비어 있을 수 있고, 비어 있음이 곧 "고르지 않았다"는 뜻이다. */
public static HomeFocusResponse homeFocus(HomeFocusConfigView view) {
HomeFocusResponse response = new HomeFocusResponse(view.id(), view.version());
response.setDefaultType(view.defaultType());
response.setCurrentProjectId(view.currentProjectId());
response.setOpenQuestionId(view.openQuestionId());
response.setRecentDecisionId(view.recentDecisionId());
return response;
}
/** 프로젝트 게시 결과. canonical path 는 공개 조회가 쓰는 {@code /projects/<slug>} 와 같은 규칙으로 만든다. */
public static PublishResponse projectPublication(ProjectEditView view) {
return new PublishResponse(
view.id(),
PublishResponse.StatusEnum.PUBLISHED,
"UNLISTED".equals(view.targetVisibility())
? PublishResponse.VisibilityEnum.UNLISTED
: PublishResponse.VisibilityEnum.PUBLIC,
"/projects/" + view.slug(),
at(view.lastPublishedAt()),
view.version());
}
private static String nullToEmpty(String value) { private static String nullToEmpty(String value) {
return value == null ? "" : value; return value == null ? "" : value;
} }
@@ -28,7 +28,6 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -8,10 +8,12 @@ import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableCell
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableColumn;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.DataTableRow;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.HeadingBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ImageBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.Inline;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ListItem;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.OrderedListBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ParagraphBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.ThematicBreakBlock;
import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock; import dev.caskeleton.adapter.inbound.web.techlog.studio.api.model.UnorderedListBlock;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -24,6 +26,7 @@ import org.commonmark.node.BlockQuote;
import org.commonmark.node.BulletList; import org.commonmark.node.BulletList;
import org.commonmark.node.FencedCodeBlock; import org.commonmark.node.FencedCodeBlock;
import org.commonmark.node.Heading; import org.commonmark.node.Heading;
import org.commonmark.node.Image;
import org.commonmark.node.IndentedCodeBlock; import org.commonmark.node.IndentedCodeBlock;
import org.commonmark.node.Node; import org.commonmark.node.Node;
import org.commonmark.node.OrderedList; import org.commonmark.node.OrderedList;
@@ -38,10 +41,13 @@ import org.commonmark.node.ThematicBreak;
*/ */
final class BlockRenderer { final class BlockRenderer {
/** 계약 {@code HeadingBlock.level} 은 2..4 다. Markdown 의 h1/h5/h6 는 이 범위로 접는다. */ /**
private static final int MIN_HEADING_LEVEL = 2; * 계약 {@code HeadingBlock.level} 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 였고 h1/h5/h6 범위로 접었는데,
* 그러면 작성자가 위계가 화면에서 달라진다. 지금은 그대로 담는다.
*/
private static final int MIN_HEADING_LEVEL = 1;
private static final int MAX_HEADING_LEVEL = 4; private static final int MAX_HEADING_LEVEL = 6;
private final HeadingIds headingIds; private final HeadingIds headingIds;
private final List<String> warnings; private final List<String> warnings;
@@ -67,18 +73,18 @@ final class BlockRenderer {
private CaseRenderBlock renderBlock(Node node) { private CaseRenderBlock renderBlock(Node node) {
return switch (node) { return switch (node) {
case Heading value -> heading(value); case Heading value -> heading(value);
case Paragraph value -> paragraph(InlineRenderer.render(value)); case Paragraph value -> paragraphOrImage(value);
case BlockQuote value -> blockquote(value); case BlockQuote value -> blockquote(value);
case BulletList value -> bulletList(value); case BulletList value -> bulletList(value);
case OrderedList value -> orderedList(value); case OrderedList value -> orderedList(value);
case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo()); case FencedCodeBlock value -> code(value.getLiteral(), value.getInfo());
case IndentedCodeBlock value -> code(value.getLiteral(), null); case IndentedCodeBlock value -> code(value.getLiteral(), null);
case TableBlock value -> table(value); case TableBlock value -> table(value);
// 계약에 수평선 타입이 생겼다(studio-v1 3.1.0). 예전에는 담을 곳이 없어 버리고 경고했다.
case ThematicBreak ignored -> { case ThematicBreak ignored -> {
// 계약의 CaseRenderBlock 수평선 타입이 없다. 다른 블록으로 바꿔 넣으면 원문에 없던 ThematicBreakBlock block = new ThematicBreakBlock();
// 구조가 생기므로 버리고 경고한다. block.setType(ThematicBreakBlock.TypeEnum.THEMATIC_BREAK);
warnings.add("THEMATIC_BREAK_NOT_RENDERABLE"); yield block;
yield null;
} }
default -> { default -> {
List<Inline> content = InlineRenderer.render(node); List<Inline> content = InlineRenderer.render(node);
@@ -87,6 +93,47 @@ final class BlockRenderer {
}; };
} }
/**
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
*
* <p>Markdown 에서 {@code ![alt](/media/...)} 문단 안의 inline 인데 계약의 Inline union 에는 그림이 없다. 예전에는
* 그래서 대체 텍스트만 남기고 그림을 버렸다. 계약에 블록이 생겼으므로 (studio-v1 3.1.0) 문단이 그림 하나로만 이루어진 경우를 블록으로 올린다.
*
* <p>주소는 상대 경로이거나 http(s) 여야 한다 {@code javascript:} 같은 스킴이 화면에 그대로 실리면 된다.
*/
private CaseRenderBlock paragraphOrImage(Paragraph value) {
Node only = value.getFirstChild();
if (only instanceof Image image && only == value.getLastChild()) {
String source = image.getDestination();
if (isRenderableImageSource(source)) {
ImageBlock block = new ImageBlock();
block.setType(ImageBlock.TypeEnum.IMAGE);
block.setSrc(source);
block.setAlt(InlineRenderer.plainText(image));
block.setTitle(image.getTitle());
return block;
}
warnings.add("IMAGE_SOURCE_NOT_RENDERABLE");
}
return paragraph(InlineRenderer.render(value));
}
private static boolean isRenderableImageSource(String source) {
if (source == null || source.isBlank()) {
return false;
}
// 프로토콜 상대 주소(`//host/`) 경로가 아니다. 아래의 단일 슬래시 검사보다 먼저 걸러야
// 한다 순서를 바꾸면 외부 호스트를 내부 경로로 오인한다.
if (source.startsWith("//")) {
return false;
}
if (source.startsWith("/")) {
return true;
}
String lower = source.toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("http://") || lower.startsWith("https://");
}
private CaseRenderBlock heading(Heading value) { private CaseRenderBlock heading(Heading value) {
HeadingBlock block = new HeadingBlock(); HeadingBlock block = new HeadingBlock();
block.setType(HeadingBlock.TypeEnum.HEADING); block.setType(HeadingBlock.TypeEnum.HEADING);
@@ -57,9 +57,10 @@ class StudioContentRendererTest {
List<CaseRenderBlock> blocks = rendered.blocks(); List<CaseRenderBlock> blocks = rendered.blocks();
assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance); assertThat(blocks).hasSize(4).allMatch(HeadingBlock.class::isInstance);
// 계약의 HeadingBlock.level 2..4 . h1 h6 그대로 내보내면 계약 위반이다. // 계약의 HeadingBlock.level 1..6 이다(studio-v1 3.1.0). 예전에는 2..4 h1/h6 접었고,
assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(2); // 그래서 작성자가 위계가 화면에서 달라졌다. 지금은 그대로 내보낸다.
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(4); assertThat(((HeadingBlock) blocks.get(0)).getLevel()).isEqualTo(1);
assertThat(((HeadingBlock) blocks.get(1)).getLevel()).isEqualTo(6);
assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow"); assertThat(((HeadingBlock) blocks.get(0)).getId()).isEqualTo("authorization-code-flow");
assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론"); assertThat(((HeadingBlock) blocks.get(2)).getId()).isEqualTo("결론");
@@ -0,0 +1,104 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.management;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* focus 설정 저장소.
*
* <p>{@code home_focus_config} PK 고정 UUID CHECK 되어 있는 단일 테이블이다. 그래서 조회에 WHERE 없고, 갱신은
* 고정 id 그대로 쓴다 행이 여러 개일 없으므로 "어느 행" 고를 일이 없다.
*
* <p>V9 마이그레이션이 슬롯이 모두 비어 있는 행을 미리 넣는다. 따라서 {@link #load()} 항상 값을 돌려주며, 결과는 스키마가 깨진 경우뿐이라 그때는
* 예외가 맞다.
*/
@Repository
public class JdbcHomeFocusConfigAdapter implements HomeFocusConfigPort {
private static final UUID ROW_ID = UUID.fromString("00000000-0000-0000-0000-000000000003");
private static final String COLUMNS =
"id, version, default_focus_type, current_project_id, open_question_id, recent_decision_id";
private final JdbcClient jdbcClient;
public JdbcHomeFocusConfigAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
private static HomeFocusConfigView map(ResultSet rs, int rowNum) throws SQLException {
return new HomeFocusConfigView(
rs.getObject("id", UUID.class),
rs.getLong("version"),
rs.getString("default_focus_type"),
rs.getObject("current_project_id", UUID.class),
rs.getObject("open_question_id", UUID.class),
rs.getObject("recent_decision_id", UUID.class));
}
@Override
public HomeFocusConfigView load() {
return jdbcClient
.sql("SELECT " + COLUMNS + " FROM home_focus_config")
.query(JdbcHomeFocusConfigAdapter::map)
.optional()
.orElseThrow(
() ->
new IllegalStateException("home_focus_config has no row; migration V9 seeds one"));
}
@Override
public Optional<HomeFocusConfigView> update(UpdateHomeFocusCommand command) {
int updated =
jdbcClient
.sql(
"UPDATE home_focus_config SET default_focus_type = :defaultType,"
+ " current_project_id = :projectId, open_question_id = :questionId,"
+ " recent_decision_id = :decisionId, version = version + 1,"
+ " updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", ROW_ID)
.param("expected", command.expectedVersion())
.param("defaultType", command.defaultType())
.param("projectId", command.currentProjectId())
.param("questionId", command.openQuestionId())
.param("decisionId", command.recentDecisionId())
.param("actor", command.actor())
.update();
return updated == 0 ? Optional.empty() : Optional.of(load());
}
@Override
public boolean projectExists(UUID id) {
return exists("project", id);
}
@Override
public boolean questionExists(UUID id) {
return exists("open_question", id);
}
@Override
public boolean decisionExists(UUID id) {
return exists("project_decision", id);
}
/*
* 테이블 이름은 클래스 안의 상수 셋에서만 온다 호출자가 문자열을 넘길 없으므로 연결해도 주입 경로가 없다.
*/
private boolean exists(String table, UUID id) {
return jdbcClient
.sql("SELECT 1 FROM " + table + " WHERE id = :id")
.param("id", id)
.query(Integer.class)
.optional()
.isPresent();
}
}
@@ -5,11 +5,15 @@ import dev.caskeleton.application.techlog.management.command.UpdateProjectComman
import dev.caskeleton.application.techlog.management.model.ProjectEditView; import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView; import dev.caskeleton.application.techlog.management.model.ProjectIndexItemView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -230,4 +234,142 @@ public class JdbcProjectRepositoryAdapter implements ProjectRepositoryPort {
.query(Boolean.class) .query(Boolean.class)
.single()); .single());
} }
/**
* 게시는 곳을 트랜잭션 안에서 함께 세운다 원본의 상태, 공개 투영, canonical route. 어느 하나가 빠지면 증상이 제각각이다: 투영이 없으면 화면이
* 조용히 비고, route 없으면 주소만 404 되며, 원본 상태가 바뀌면 Studio 계속 "초안" 이라고 말한다.
*
* <p>낙관적 잠금이 먼저다. 버전이 어긋나면 아래 문장은 아예 실행하지 않는다.
*/
@Override
public Optional<ProjectEditView> publish(
UUID id, long expectedVersion, String visibility, String actor) {
int updated =
jdbcClient
.sql(
"UPDATE project SET workflow_status = 'PUBLISHED', target_visibility = :visibility,"
+ " first_published_at = COALESCE(first_published_at, now()),"
+ " last_published_at = now(), version = version + 1,"
+ " updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("visibility", visibility)
.param("actor", actor)
.update();
if (updated == 0) {
return Optional.empty();
}
ProjectEditView project =
find(id)
.orElseThrow(() -> new IllegalStateException("project vanished mid-publish: " + id));
upsertProjection(project, visibility);
replaceRoute(project);
return Optional.of(project);
}
@Override
public Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor) {
int updated =
jdbcClient
.sql(
"UPDATE project SET workflow_status = 'DRAFT', target_visibility = 'PRIVATE',"
+ " version = version + 1, updated_at = now(), updated_by = :actor"
+ " WHERE id = :id AND version = :expected")
.param("id", id)
.param("expected", expectedVersion)
.param("actor", actor)
.update();
if (updated == 0) {
return Optional.empty();
}
jdbcClient
.sql(
"UPDATE public_resource_projection SET publication_state = 'WITHDRAWN',"
+ " updated_at = now() WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", id)
.update();
return find(id);
}
/**
* 공개 조회들은 실제 내용을 {@code project} 테이블에서 직접 읽고 행은 가시성 관문·주소·정렬 시각으로만 쓴다. 그래서 {@code payload}
* 객체로 둔다 여기에 사본을 두면 원본이 바뀔 때마다 곳이 어긋난다.
*
* <p>{@code search_text} 만은 예외다. 검색은 테이블 하나만 훑으므로 여기에 없는 낱말은 영영 찾을 없다.
*/
private void upsertProjection(ProjectEditView project, String visibility) {
String searchText =
String.join(
" ",
nullToEmpty(project.name()),
nullToEmpty(project.oneLinePurpose()),
nullToEmpty(project.purposeMarkdown()),
nullToEmpty(project.currentObjective()),
nullToEmpty(project.nextStep()));
jdbcClient
.sql(
"INSERT INTO public_resource_projection (resource_type, resource_id, source_version,"
+ " publication_state, visibility, title, summary, state_code,"
+ " payload_schema_version, payload, body_plain_text, search_text, content_hash,"
+ " published_at, updated_at, navigation_path)"
+ " VALUES ('PROJECT', :id, :version, 'ACTIVE', :visibility, :title, :summary,"
+ " :stateCode, 1, '{}'::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,"
+ " 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("id", project.id())
.param("version", project.version())
.param("visibility", visibility)
.param("title", project.name())
.param("summary", project.oneLinePurpose())
.param("stateCode", project.phase())
.param("bodyPlainText", nullToEmpty(project.purposeMarkdown()))
.param("searchText", searchText)
.param("contentHash", sha256(searchText))
.param("navigationPath", "/projects/" + project.slug())
.update();
}
/** 이전 slug 의 route 는 alias 로 남긴다 — 지우면 이미 공개된 링크가 끊긴다. */
private void replaceRoute(ProjectEditView project) {
jdbcClient
.sql(
"UPDATE public_route SET route_role = 'ALIAS'"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id AND slug <> :slug")
.param("id", project.id())
.param("slug", project.slug())
.update();
jdbcClient
.sql(
"INSERT INTO public_route (resource_type, slug, resource_id, route_role)"
+ " VALUES ('PROJECT', :slug, :id, 'CANONICAL')"
+ " ON CONFLICT (resource_type, slug) DO UPDATE SET"
+ " resource_id = EXCLUDED.resource_id, route_role = 'CANONICAL'")
.param("id", project.id())
.param("slug", project.slug())
.update();
}
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);
}
}
} }
@@ -87,7 +87,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
"SELECT d.id, d.title, d.body_markdown, d.content_format," "SELECT d.id, d.title, d.body_markdown, d.content_format,"
+ " d.content_format_version, d.cover_asset_id," + " d.content_format_version, d.cover_asset_id,"
// `environment_items` V7 만든 jsonb 배열이고, Studio 쓰는 것은 V8 // `environment_items` V7 만든 jsonb 배열이고, Studio 쓰는 것은 V8
// 따로 만든 `environment`/`reproduction` 문자열이다(V8__techlog_studio_working_copy.sql:23-26). // 따로 만든 `environment`/`reproduction`
// 문자열이다(V8__techlog_studio_working_copy.sql:23-26).
// 공개 조회가 배열 쪽을 읽는 동안 컬럼을 채우는 코드는 어디에도 없었고 // 공개 조회가 배열 쪽을 읽는 동안 컬럼을 채우는 코드는 어디에도 없었고
// 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 칸은 비어 있었다. // 작성자가 검증 환경과 재현 조건을 채워도 공개 화면의 칸은 비어 있었다.
+ " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction," + " c.problem_summary, c.conclusion_summary, c.environment, c.reproduction,"
@@ -229,8 +230,8 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
/** /**
* 본문이 {@code :::evidence key="..."} 가리키는 Asset. * 본문이 {@code :::evidence key="..."} 가리키는 Asset.
* *
* <p>{@code PUBLISHED} scope 참조만 읽는다 게시 이후 작업본이 Asset 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 * <p>{@code PUBLISHED} scope 참조만 읽는다 게시 이후 작업본이 Asset 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않아야 하기
* 하기 때문이다. {@code WORKING} scope Studio 것이다. * 때문이다. {@code WORKING} scope Studio 것이다.
* *
* <p>{@code READY} 아닌 Asset 뺀다. {@code /media/{assetId}} 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를 * <p>{@code READY} 아닌 Asset 뺀다. {@code /media/{assetId}} 그것만 서빙하므로, 넣어 두면 공개 화면이 404 나는 주소를
* 가리키게 된다. * 가리키게 된다.
@@ -275,15 +276,16 @@ public class JdbcPublicDocumentQueryAdapter implements PublicDocumentQueryPort {
/** /**
* 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다. * 검증 환경과 재현 조건을 공개 계약의 목록 자리에 담는다.
* *
* <p>계약은 `environmentSummary` 문자열 배열로 두는데 Studio 채우는 것은 개의 문자열 * <p>계약은 `environmentSummary` 문자열 배열로 두는데 Studio 채우는 것은 개의 문자열 (`environment`,
* (`environment`, `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 단위로 * `reproduction`)이다. 여기서는 비어 있지 않은 것만 순서대로 넣는다 단위로 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
* 쪼개는 것은 표현 정책이라 어댑터가 정할 일이 아니다.
*/ */
static List<String> environment(ResultSet rs) throws SQLException { static List<String> environment(ResultSet rs) throws SQLException {
List<String> items = new ArrayList<>(2); List<String> items = new ArrayList<>(2);
for (String column : List.of("environment", "reproduction")) { for (String column : List.of("environment", "reproduction")) {
String value = rs.getString(column); String value = rs.getString(column);
if (value != null && !value.isBlank()) items.add(value); if (value != null && !value.isBlank()) {
items.add(value);
}
} }
return List.copyOf(items); return List.copyOf(items);
} }
@@ -13,11 +13,21 @@ import org.springframework.stereotype.Repository;
/** /**
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4). * catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
* *
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운. 그때까지 페이지를 반환하며 이는 계약상 유효한 응답이다. * <p>RELATION EVIDENCE 같은 기록을 서로 다른 시점에서 . RELATION <em>작성 </em> 고르는 것이라 아직 게시되지 않은 작업본까지
* 포함한다 문서를 같이 쓰면서 서로 잇는 것이 정상적인 순서이고, 게시된 것만 보이면 순서를 없다. EVIDENCE 공개된 기록을 근거로 인용하는 것이므로
* 공개 투영에 살아 있는 행만 포함한다. 읽는 사람이 따라갈 없는 근거는 근거가 아니다.
*/ */
@Repository @Repository
public class JdbcCatalogQueryAdapter implements CatalogQueryPort { public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
/** 공개 노출 조건. {@code PublicSql.ACTIVE} 와 같은 정의다 — 그쪽은 package-private 이라 여기에 다시 적는다. */
private static final String PUBLICLY_VISIBLE =
" p.publication_state = 'ACTIVE' AND p.visibility = 'PUBLIC' ";
/** 연결·근거 대상이 될 수 있는 기록 유형. PROJECT 는 관계가 아니라 소속이라 여기에 없다. */
private static final String LINKABLE_TYPES =
" ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION') ";
private final JdbcClient jdbcClient; private final JdbcClient jdbcClient;
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) { public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
@@ -32,7 +42,8 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
switch (type) { switch (type) {
case TOPIC -> searchTopics(pattern, limit); case TOPIC -> searchTopics(pattern, limit);
case PROJECT -> searchProjects(pattern, limit); case PROJECT -> searchProjects(pattern, limit);
case RELATION, EVIDENCE -> List.of(); case RELATION -> searchRelations(pattern, limit);
case EVIDENCE -> searchEvidence(pattern, limit);
}; };
return new CatalogPageView(items, null); return new CatalogPageView(items, null);
} }
@@ -75,4 +86,79 @@ public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
"project:" + rs.getTimestamp("updated_at").toInstant())) "project:" + rs.getTimestamp("updated_at").toInstant()))
.list(); .list();
} }
/**
* 원천 테이블을 하나의 목록으로 합친다. {@code document} {@code document_type} 그대로 계약의 {@code kind} 이고, 나머지
* 둘은 테이블 자체가 유형을 정한다.
*
* <p>공개 경로는 게시된 것에만 있으므로 LEFT JOIN 이다. 작업본은 {@code publicPath} null 이고, 이는 "아직 공개 주소가 없다" 뜻이지
* "고를 수 없다" 뜻이 아니다.
*/
private List<CatalogEntryView> searchRelations(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, kind, label, public_path, updated_at FROM ("
+ " SELECT d.id AS id, d.document_type AS kind, d.title AS label,"
+ " p.navigation_path AS public_path, d.updated_at AS updated_at"
+ " FROM document d"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = d.document_type AND p.resource_id = d.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(d.title) LIKE :pattern"
+ " UNION ALL"
+ " SELECT q.id, 'QUESTION', q.question,"
+ " p.navigation_path, q.updated_at"
+ " FROM open_question q"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = 'QUESTION' AND p.resource_id = q.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(q.question) LIKE :pattern"
+ " UNION ALL"
+ " SELECT pd.id, 'PROJECT_DECISION', pd.title,"
+ " p.navigation_path, pd.updated_at"
+ " FROM project_decision pd"
+ " LEFT JOIN public_resource_projection p"
+ " ON p.resource_type = 'PROJECT_DECISION' AND p.resource_id = pd.id"
+ " AND "
+ PUBLICLY_VISIBLE
+ " WHERE lower(pd.title) LIKE :pattern"
+ ") linkable ORDER BY label LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query((rs, rowNum) -> entry(rs, CatalogEntryType.RELATION, "relation"))
.list();
}
/** 공개 투영이 곧 "읽는 사람이 따라갈 수 있는 기록"의 정의다. 여기서는 그 테이블 하나면 충분하다. */
private List<CatalogEntryView> searchEvidence(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT p.resource_id AS id, p.resource_type AS kind, p.title AS label,"
+ " p.navigation_path AS public_path, p.updated_at AS updated_at"
+ " FROM public_resource_projection p"
+ " WHERE "
+ PUBLICLY_VISIBLE
+ " AND p.resource_type IN "
+ LINKABLE_TYPES
+ " AND lower(p.title) LIKE :pattern"
+ " ORDER BY label LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query((rs, rowNum) -> entry(rs, CatalogEntryType.EVIDENCE, "evidence"))
.list();
}
private static CatalogEntryView entry(
java.sql.ResultSet rs, CatalogEntryType type, String revisionPrefix)
throws java.sql.SQLException {
return new CatalogEntryView(
UUID.fromString(rs.getString("id")),
type,
rs.getString("label"),
rs.getString("kind"),
rs.getString("public_path"),
revisionPrefix + ":" + rs.getTimestamp("updated_at").toInstant());
}
} }
@@ -4,6 +4,13 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapter;
import dev.caskeleton.application.techlog.management.command.CreateProjectCommand;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import java.util.UUID; import java.util.UUID;
import org.flywaydb.core.Flyway; import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterAll;
@@ -12,6 +19,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory; import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer; import org.testcontainers.postgresql.PostgreSQLContainer;
import tools.jackson.databind.ObjectMapper;
/** /**
* 작업본 삭제 SQL 실제 PostgreSQL 위에서 돌린다. * 작업본 삭제 SQL 실제 PostgreSQL 위에서 돌린다.
@@ -32,6 +40,9 @@ class ManagementPersistenceIntegrationTest {
private static HikariDataSource dataSource; private static HikariDataSource dataSource;
private static JdbcClient jdbcClient; private static JdbcClient jdbcClient;
private static JdbcDocumentDeletionAdapter deletion; private static JdbcDocumentDeletionAdapter deletion;
private static JdbcProjectRepositoryAdapter projects;
private static JdbcHomeFocusConfigAdapter homeFocus;
private static JdbcCatalogQueryAdapter catalog;
@BeforeAll @BeforeAll
static void migrate() { static void migrate() {
@@ -61,6 +72,9 @@ class ManagementPersistenceIntegrationTest {
jdbcClient = JdbcClient.create(dataSource); jdbcClient = JdbcClient.create(dataSource);
deletion = new JdbcDocumentDeletionAdapter(jdbcClient); deletion = new JdbcDocumentDeletionAdapter(jdbcClient);
projects = new JdbcProjectRepositoryAdapter(jdbcClient, new ObjectMapper());
homeFocus = new JdbcHomeFocusConfigAdapter(jdbcClient);
catalog = new JdbcCatalogQueryAdapter(jdbcClient);
} }
@AfterAll @AfterAll
@@ -396,4 +410,180 @@ class ManagementPersistenceIntegrationTest {
assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1); assertThat(deletion.deleteDecision(id, 0L)).isEqualTo(1);
assertThat(deletion.findDecision(projectId, id)).isEmpty(); assertThat(deletion.findDecision(projectId, id)).isEmpty();
} }
// ---------------------------------------------------------------- 프로젝트 게시
/**
* 프로젝트 게시는 원본 상태·공개 투영·canonical route 곳을 함께 세운다. 어느 하나가 빠지면 증상이 제각각이라 (투영이 없으면 화면이 조용히 비고,
* route 없으면 주소만 404) 곳을 한꺼번에 확인한다.
*/
@Test
void publishingAProjectRaisesTheProjection() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Publish Me", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'publish-me' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
ProjectEditView published =
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
assertThat(published.targetVisibility()).isEqualTo("PUBLIC");
assertThat(published.firstPublishedAt()).isNotNull();
assertThat(
jdbcClient
.sql(
"SELECT navigation_path FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id"
+ " AND publication_state = 'ACTIVE' AND visibility = 'PUBLIC'")
.param("id", loaded.id())
.query(String.class)
.optional())
.contains("/projects/publish-me");
assertThat(
jdbcClient
.sql(
"SELECT route_role FROM public_route"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", loaded.id())
.query(String.class)
.list())
.containsExactly("CANONICAL");
}
/** 게시 취소는 투영 행을 지우지 않고 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
@Test
void unpublishingWithdrawsTheProjectionInsteadOfDeletingIt() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Withdraw Me", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'withdraw-me' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
ProjectEditView published =
projects.publish(loaded.id(), loaded.version(), "PUBLIC", "test").orElseThrow();
assertThat(projects.unpublish(published.id(), published.version(), "test")).isPresent();
assertThat(
jdbcClient
.sql(
"SELECT publication_state FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", published.id())
.query(String.class)
.optional())
.contains("WITHDRAWN");
}
/** 낙관적 잠금. 어긋난 버전으로는 아무것도 세우지 않는다. */
@Test
void publishingWithAStaleVersionChangesNothing() {
ProjectEditView draft = projects.create(new CreateProjectCommand("Stale", "test"));
jdbcClient
.sql("UPDATE project SET slug = 'stale' WHERE id = :id")
.param("id", draft.id())
.update();
ProjectEditView loaded = projects.find(draft.id()).orElseThrow();
assertThat(projects.publish(loaded.id(), loaded.version() + 99, "PUBLIC", "test")).isEmpty();
assertThat(
jdbcClient
.sql(
"SELECT count(*) FROM public_resource_projection"
+ " WHERE resource_type = 'PROJECT' AND resource_id = :id")
.param("id", loaded.id())
.query(Integer.class)
.single())
.isZero();
}
// ---------------------------------------------------------------- focus
/** V9 가 빈 행 하나를 넣어 둔다. 조회가 실패하면 그 전제가 깨진 것이다. */
@Test
void homeFocusStartsAsASingleEmptyRow() {
HomeFocusConfigView current = homeFocus.load();
assertThat(current.currentProjectId()).isNull();
assertThat(current.openQuestionId()).isNull();
assertThat(current.recentDecisionId()).isNull();
}
@Test
void homeFocusRoundTripsAndRefusesAStaleVersion() {
ProjectEditView project = projects.create(new CreateProjectCommand("Focus Target", "test"));
HomeFocusConfigView before = homeFocus.load();
HomeFocusConfigView saved =
homeFocus
.update(
new UpdateHomeFocusCommand(
before.version(), "CURRENT_WORK", project.id(), null, null, "test"))
.orElseThrow();
assertThat(saved.currentProjectId()).isEqualTo(project.id());
assertThat(saved.defaultType()).isEqualTo("CURRENT_WORK");
assertThat(saved.version()).isEqualTo(before.version() + 1);
assertThat(homeFocus.projectExists(project.id())).isTrue();
assertThat(homeFocus.projectExists(UUID.randomUUID())).isFalse();
assertThat(
homeFocus.update(
new UpdateHomeFocusCommand(before.version(), null, null, null, null, "test")))
.isEmpty();
// null "이 슬롯을 비운다" 부분 수정이 아니라 전체 교체.
HomeFocusConfigView cleared =
homeFocus
.update(new UpdateHomeFocusCommand(saved.version(), null, null, null, null, "test"))
.orElseThrow();
assertThat(cleared.currentProjectId()).isNull();
}
// ---------------------------------------------------------------- catalog
/** RELATION 은 작성 중에 고르는 것이므로 게시 여부와 무관하게 작업본까지 포함한다. */
@Test
void relationCatalogIncludesUnpublishedWorkingCopies() {
UUID caseId = insertCase("Relation Target Case");
UUID questionId = insertQuestion("Relation Target Question");
CatalogPageView page = catalog.search(CatalogEntryType.RELATION, null, null, 50);
assertThat(page.items()).extracting("id").contains(caseId, questionId);
assertThat(page.items())
.filteredOn(entry -> entry.id().equals(caseId))
.singleElement()
.satisfies(
entry -> {
assertThat(entry.kind()).isEqualTo("CASE");
assertThat(entry.publicPath()).isNull();
});
}
/** EVIDENCE 는 읽는 사람이 따라갈 수 있어야 하므로 공개된 것만 포함한다. */
@Test
void evidenceCatalogIncludesOnlyPubliclyVisibleRecords() {
UUID publishedId = insertCase("Evidence Published");
UUID draftId = insertCase("Evidence Draft");
UUID withdrawnId = insertCase("Evidence Withdrawn");
insertProjection("CASE", publishedId, "ACTIVE", "Evidence Published");
insertProjection("CASE", withdrawnId, "WITHDRAWN", "Evidence Withdrawn");
CatalogPageView page = catalog.search(CatalogEntryType.EVIDENCE, "evidence", null, 50);
assertThat(page.items()).extracting("id").contains(publishedId);
assertThat(page.items()).extracting("id").doesNotContain(draftId, withdrawnId);
}
/** 검색어는 대소문자를 가리지 않는다. */
@Test
void relationCatalogSearchIsCaseInsensitive() {
UUID caseId = insertCase("MixedCase Needle");
assertThat(catalog.search(CatalogEntryType.RELATION, "mixedcase", null, 50).items())
.extracting("id")
.contains(caseId);
}
} }
@@ -1,6 +1,7 @@
package dev.caskeleton.bootstrap.techlog; package dev.caskeleton.bootstrap.techlog;
import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort; import dev.caskeleton.application.techlog.management.port.out.DocumentDeletionPort;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.ReleaseRepositoryPort;
import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort; import dev.caskeleton.application.techlog.management.port.out.TopicRepositoryPort;
@@ -13,13 +14,17 @@ import dev.caskeleton.application.techlog.management.service.DeleteProjectUseCas
import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase; import dev.caskeleton.application.techlog.management.service.DeleteQuestionUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase; import dev.caskeleton.application.techlog.management.service.DeleteReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase; import dev.caskeleton.application.techlog.management.service.DeleteTopicUseCase;
import dev.caskeleton.application.techlog.management.service.GetHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase; import dev.caskeleton.application.techlog.management.service.GetProjectForEditUseCase;
import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase; import dev.caskeleton.application.techlog.management.service.GetReleaseForEditUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioProjectsUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioReleasesUseCase;
import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase; import dev.caskeleton.application.techlog.management.service.ListStudioTopicsUseCase;
import dev.caskeleton.application.techlog.management.service.PublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase; import dev.caskeleton.application.techlog.management.service.PublishReleaseUseCase;
import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase; import dev.caskeleton.application.techlog.management.service.SaveTopicUseCase;
import dev.caskeleton.application.techlog.management.service.UnpublishProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateHomeFocusUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase; import dev.caskeleton.application.techlog.management.service.UpdateProjectUseCase;
import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase; import dev.caskeleton.application.techlog.management.service.UpdateReleaseUseCase;
import dev.caskeleton.application.transaction.TransactionPort; import dev.caskeleton.application.transaction.TransactionPort;
@@ -128,4 +133,25 @@ public class TechLogManagementConfig {
DocumentDeletionPort documents, TransactionPort tx) { DocumentDeletionPort documents, TransactionPort tx) {
return new DeleteProjectDecisionUseCase(documents, tx); return new DeleteProjectDecisionUseCase(documents, tx);
} }
@Bean
PublishProjectUseCase publishProjectUseCase(ProjectRepositoryPort projects, TransactionPort tx) {
return new PublishProjectUseCase(projects, tx);
}
@Bean
UnpublishProjectUseCase unpublishProjectUseCase(
ProjectRepositoryPort projects, TransactionPort tx) {
return new UnpublishProjectUseCase(projects, tx);
}
@Bean
GetHomeFocusUseCase getHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
return new GetHomeFocusUseCase(config, tx);
}
@Bean
UpdateHomeFocusUseCase updateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort tx) {
return new UpdateHomeFocusUseCase(config, tx);
}
} }
@@ -173,6 +173,14 @@ spring:
# true | false (Java 21 virtual threads for Tomcat request handlers) # true | false (Java 21 virtual threads for Tomcat request handlers)
enabled: ${SPRING_THREADS_VIRTUAL_ENABLED} enabled: ${SPRING_THREADS_VIRTUAL_ENABLED}
servlet: servlet:
session:
# Studio 작성자는 한 기록을 여러 번 저장하며 오래 머문다. Spring 기본 30분 유휴 만료는 그
# 리듬보다 짧아 작성 도중 로그인 화면으로 돌아가는 일이 잦았다 — 저장하지 않은 편집이
# 있으면 그 시점에 잃는다.
#
# 값은 배포가 정한다. 늘릴수록 훔친 session cookie가 유효한 창도 같이 늘어나므로,
# Keycloak realm 의 SSO idle 과 따로 놀지 않게 함께 맞춘다.
timeout: ${APP_SESSION_TIMEOUT:8h}
multipart: multipart:
# feature-api-contract-baseline D8: bound request body size so an oversized # feature-api-contract-baseline D8: bound request body size so an oversized
# upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via # upload classifies as 413 PAYLOAD_TOO_LARGE inside the envelope (via
@@ -11,6 +11,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration; import org.springframework.boot.data.jpa.autoconfigure.DataJpaRepositoriesAutoConfiguration;
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration; import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
@@ -117,13 +119,21 @@ class ActuatorSecurityHttpTest {
.andExpect(status().isForbidden()); .andExpect(status().isForbidden());
} }
/*
* Redis 함께 제외한다. 빼지 않으면 reactive Redis health indicator 슬라이스에 그대로
* 올라와 localhost:6379 붙으려 하고, 실패하면 /actuator/health 503 된다. 그러면
* 테스트는 "probe 가 permitAll 인가" 아니라 "이 기계에 Redis 가 떠 있는가" 재게 된다
* 보안 태세와 아무 상관 없는 이유로 빨개진다.
*/
@EnableAutoConfiguration( @EnableAutoConfiguration(
exclude = { exclude = {
DataSourceAutoConfiguration.class, DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class,
HibernateJpaAutoConfiguration.class, HibernateJpaAutoConfiguration.class,
DataJpaRepositoriesAutoConfiguration.class, DataJpaRepositoriesAutoConfiguration.class,
FlywayAutoConfiguration.class FlywayAutoConfiguration.class,
DataRedisAutoConfiguration.class,
DataRedisReactiveAutoConfiguration.class
}) })
@Import(ManagementSecurityConfig.class) @Import(ManagementSecurityConfig.class)
static class MinimalActuatorApp {} static class MinimalActuatorApp {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.management.command;
import java.util.UUID;
/**
* {@code updateHomeFocus}. 슬롯은 전부 nullable 이고, null "이 슬롯을 비운다" 뜻이다 부분 수정이 아니라 전체 교체다.
*
* <p>부분 수정으로 두면 "비우기" 표현할 방법이 없어진다. 화면이 칸을 번에 보여 주고 번에 저장하므로 전체 교체가 화면과도 맞다.
*/
public record UpdateHomeFocusCommand(
long expectedVersion,
String defaultType,
UUID currentProjectId,
UUID openQuestionId,
UUID recentDecisionId,
String actor) {}
@@ -0,0 +1,16 @@
package dev.caskeleton.application.techlog.management.model;
import java.util.UUID;
/**
* 화면이 무엇을 앞에 둘지 정하는 단일 설정. 계약 {@code HomeFocusResponse} application 표현.
*
* <p> 슬롯은 모두 nullable 이다. 비어 있다는 것은 "고르지 않았다" 뜻이고, 공개 화면은 그때 focus 영역을 아예 그리지 않는다.
*/
public record HomeFocusConfigView(
UUID id,
long version,
String defaultType,
UUID currentProjectId,
UUID openQuestionId,
UUID recentDecisionId) {}
@@ -0,0 +1,22 @@
package dev.caskeleton.application.techlog.management.port.out;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import java.util.Optional;
import java.util.UUID;
/** 홈 focus 설정의 편집용 읽기/쓰기. 공개 조회는 {@code publicsite} 쪽 포트가 따로 소유한다. */
public interface HomeFocusConfigPort {
/** 단일 행이므로 조회는 실패하지 않는다. 마이그레이션이 빈 행을 이미 넣어 두었다. */
HomeFocusConfigView load();
/** 낙관적 잠금. 버전이 어긋나면 {@code Optional.empty()}. */
Optional<HomeFocusConfigView> update(UpdateHomeFocusCommand command);
boolean projectExists(UUID id);
boolean questionExists(UUID id);
boolean decisionExists(UUID id);
}
@@ -25,5 +25,16 @@ public interface ProjectRepositoryPort {
boolean isReferenced(UUID id); boolean isReferenced(UUID id);
/**
* 공개 게시. 프로젝트는 Studio 문서가 아니라 {@code RecordKind} 없고, 따라서 문서 게시 파이프라인을 타지 않는다. 공개 화면들은 모두 {@code
* public_resource_projection} {@code PROJECT} 행을 가시성 관문으로 쓰므로, 행을 세우는 것이 게시다.
*
* <p>버전이 어긋나면 {@code Optional.empty()}.
*/
Optional<ProjectEditView> publish(UUID id, long expectedVersion, String visibility, String actor);
/** 게시 취소. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다 — 지우면 다시 게시할 때 이력이 끊긴다. */
Optional<ProjectEditView> unpublish(UUID id, long expectedVersion, String actor);
boolean slugTaken(String slug, UUID exceptId); boolean slugTaken(String slug, UUID exceptId);
} }
@@ -0,0 +1,35 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
/*
* final 아닌 이유는 다른 use case 들과 같다 @RequiresPermission CGLIB 프록시로 강제된다.
*/
@RequiresPermission(StudioPermissions.READ)
@UseCaseCapability(
transactionMode = TransactionMode.READ_ONLY,
idempotency = Idempotency.IDEMPOTENT,
repositoryAccess = RepositoryAccess.READ_REPOSITORY)
public class GetHomeFocusUseCase {
private final HomeFocusConfigPort config;
private final TransactionPort transactions;
public GetHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) {
this.config = Objects.requireNonNull(config, "config");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public HomeFocusConfigView handle() {
return transactions.inRead(config::load);
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* {@code publishProject}.
*
* <p>프로젝트는 Studio 문서가 아니다 {@code RecordKind} 없고 본문도 검증 대상도 없다. 그래서 문서 게시 파이프라인 대신 여기서 직접 공개 상태를
* 세운다. 릴리스가 자체 경로를 갖는 것과 같은 이유다.
*
* <p>slug 없으면 거절한다. 공개 주소가 {@code /projects/<slug>} 이므로 slug 없이 게시하면 아무도 닿을 없는 페이지가 생긴다 저장은
* 성공했는데 링크는 없는, 이유를 없는 상태다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class PublishProjectUseCase {
private static final Set<String> VISIBILITIES = Set.of("PUBLIC", "UNLISTED");
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public PublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectEditView handle(UUID id, long expectedVersion, String visibility, String actor) {
Objects.requireNonNull(id, "id");
String requested = visibility == null ? "PUBLIC" : visibility;
if (!VISIBILITIES.contains(requested)) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "visibility must be PUBLIC or UNLISTED");
}
return transactions.inWrite(
() -> {
ProjectEditView current =
projects
.find(id)
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project"));
if (current.slug() == null || current.slug().isBlank()) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED,
"a slug is required before a project can be published");
}
return projects
.publish(id, expectedVersion, requested, actor)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the project changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
}
@@ -0,0 +1,54 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.model.ProjectEditView;
import dev.caskeleton.application.techlog.management.port.out.ProjectRepositoryPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.UUID;
/** {@code unpublishProject}. 투영 행은 지우지 않고 {@code WITHDRAWN} 으로 내린다. */
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UnpublishProjectUseCase {
private final ProjectRepositoryPort projects;
private final TransactionPort transactions;
public UnpublishProjectUseCase(ProjectRepositoryPort projects, TransactionPort transactions) {
this.projects = Objects.requireNonNull(projects, "projects");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public ProjectEditView handle(UUID id, long expectedVersion, String actor) {
Objects.requireNonNull(id, "id");
return transactions.inWrite(
() -> {
ProjectEditView current =
projects
.find(id)
.orElseThrow(
() ->
ManagementException.of(
ManagementError.PROJECT_NOT_FOUND, "no such project"));
return projects
.unpublish(id, expectedVersion, actor)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the project changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.application.techlog.management.service;
import dev.caskeleton.application.capability.Idempotency;
import dev.caskeleton.application.capability.RepositoryAccess;
import dev.caskeleton.application.capability.UseCaseCapability;
import dev.caskeleton.application.security.RequiresPermission;
import dev.caskeleton.application.techlog.error.ManagementError;
import dev.caskeleton.application.techlog.error.ManagementException;
import dev.caskeleton.application.techlog.management.command.UpdateHomeFocusCommand;
import dev.caskeleton.application.techlog.management.model.HomeFocusConfigView;
import dev.caskeleton.application.techlog.management.port.out.HomeFocusConfigPort;
import dev.caskeleton.application.techlog.studio.service.StudioPermissions;
import dev.caskeleton.application.transaction.TransactionMode;
import dev.caskeleton.application.transaction.TransactionPort;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* {@code updateHomeFocus}.
*
* <p>지목한 대상이 실제로 있는지 여기서 먼저 확인한다. 테이블에는 FK 없어서 (설정이 대상보다 오래 살아남는 것을 허용하는 설계다) 없는 id 그대로 저장할
* 있고, 그러면 공개 화면은 조용히 focus 그린다 저장은 성공했는데 화면에는 아무것도 나오는, 이유를 없는 실패가 된다.
*
* <p>대상이 <em>공개</em>인지는 확인하지 않는다. 아직 게시하지 않은 프로젝트를 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인 순서다. 공개 여부는
* 공개 조회 쪽이 매번 다시 판단한다.
*/
@RequiresPermission(StudioPermissions.WRITE)
@UseCaseCapability(
transactionMode = TransactionMode.WRITE,
idempotency = Idempotency.NOT_IDEMPOTENT,
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
public class UpdateHomeFocusUseCase {
private static final Set<String> DEFAULT_TYPES =
Set.of("CURRENT_WORK", "OPEN_QUESTION", "RECENT_DECISION");
private final HomeFocusConfigPort config;
private final TransactionPort transactions;
public UpdateHomeFocusUseCase(HomeFocusConfigPort config, TransactionPort transactions) {
this.config = Objects.requireNonNull(config, "config");
this.transactions = Objects.requireNonNull(transactions, "transactions");
}
public HomeFocusConfigView handle(UpdateHomeFocusCommand command) {
Objects.requireNonNull(command, "command");
if (command.defaultType() != null && !DEFAULT_TYPES.contains(command.defaultType())) {
throw ManagementException.of(
ManagementError.REQUEST_VALIDATION_FAILED, "defaultType is not a known focus type");
}
return transactions.inWrite(
() -> {
requireExists(
command.currentProjectId(),
config::projectExists,
ManagementError.PROJECT_NOT_FOUND,
"no such project");
requireExists(
command.openQuestionId(),
config::questionExists,
ManagementError.QUESTION_NOT_FOUND,
"no such question");
requireExists(
command.recentDecisionId(),
config::decisionExists,
ManagementError.DECISION_NOT_FOUND,
"no such decision");
HomeFocusConfigView current = config.load();
return config
.update(command)
.orElseThrow(
() ->
ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
"the home focus changed since it was loaded",
new SaveTopicUseCase.VersionConflict(current.version())));
});
}
private static void requireExists(
UUID id, java.util.function.Predicate<UUID> exists, ManagementError error, String message) {
if (id != null && !exists.test(id)) {
throw ManagementException.of(error, message);
}
}
}
@@ -6,8 +6,8 @@ import java.util.UUID;
* 계약 {@code BodyAsset}. * 계약 {@code BodyAsset}.
* *
* <p>본문이 {@code :::evidence key="..."} 가리키는 Asset 이다. 본문은 Markdown 원문으로 나가고 안에는 key 있는데 * <p>본문이 {@code :::evidence key="..."} 가리키는 Asset 이다. 본문은 Markdown 원문으로 나가고 안에는 key 있는데
* {@code /media/{assetId}} UUID 로만 서빙하므로 주소가 추측 불가능한 것이 의도된 성질이다 key 에서 주소를 만들 없다. 공개 화면이 key * {@code /media/{assetId}} UUID 로만 서빙하므로 주소가 추측 불가능한 것이 의도된 성질이다 key 에서 주소를 만들 없다. 공개 화면이
* 해석할 있도록 게시된 기록이 실제로 참조하는 Asset 함께 준다. * key 해석할 있도록 게시된 기록이 실제로 참조하는 Asset 함께 준다.
* *
* @param url 검증된 전송 경로다. object storage URL 아니다(설계 05장 §3.1). * @param url 검증된 전송 경로다. object storage URL 아니다(설계 05장 §3.1).
* @param decorative 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 값으로 판정하므로 공개 화면도 같은 값을 보고 {@code alt} 정해야 * @param decorative 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 값으로 판정하므로 공개 화면도 같은 값을 보고 {@code alt} 정해야
+6
View File
@@ -455,6 +455,12 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
useJUnitPlatform { useJUnitPlatform {
excludeTags 'quarantine' excludeTags 'quarantine'
} }
// Gradle 512m . JVM Spring context
// (@SpringBootTest ) ArchUnit
// Testcontainers . 512m OOM ,
// "Gradle Test Executor 를 완료할 수 없음"
// .
maxHeapSize = '2g'
} }
// feature-ci-quality-gates-contract §4 (D7/D9) flaky quarantine bucket. Runs ONLY // feature-ci-quality-gates-contract §4 (D7/D9) flaky quarantine bucket. Runs ONLY
+6 -6
View File
@@ -1,6 +1,6 @@
# source: tech-log-design-package contracts/openapi/studio-v1.yaml @ b20d7a2 (feature/response-envelope-adr-006) # source: tech-log-design-package contracts/openapi/studio-v1.yaml @ ed04872 (master)
6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4 studio-v1.yaml 6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f studio-v1.yaml
# source: tech-log-design-package contracts/openapi/public-v1.yaml @ 55a9599 (feature/public-v1-response-envelope) # source: tech-log-design-package contracts/openapi/public-v1.yaml @ ed04872 (master)
8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e public-v1.yaml 37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b public-v1.yaml
# source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ 6ef5c1c (master) # source: tech-log-design-package contracts/openapi/studio-management-v1.yaml @ ed04872 (master)
ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f studio-management-v1.yaml c143ad3303eb05a02320940e2a0f5300a38b0c05f40b5bd5cf30960ef8004573 studio-management-v1.yaml
+72 -57
View File
@@ -3015,49 +3015,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PublishResponse' $ref: '#/components/schemas/PublishResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -3085,49 +3085,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ProjectEditResponse' $ref: '#/components/schemas/ProjectEditResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -4973,7 +4973,7 @@ paths:
$ref: '#/components/schemas/ExpectedVersionRequest' $ref: '#/components/schemas/ExpectedVersionRequest'
security: security:
- sessionCookie: [] - sessionCookie: []
/api/v1/studio/home-focus: /api/v1/studio/home/focus:
get: get:
operationId: getHomeFocus operationId: getHomeFocus
tags: tags:
@@ -4985,37 +4985,37 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HomeFocusResponse' $ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
security: security:
- sessionCookie: [] - sessionCookie: []
put: put:
@@ -5030,49 +5030,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HomeFocusResponse' $ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -5510,6 +5510,21 @@ components:
$ref: '#/components/schemas/ReleaseEditResponse' $ref: '#/components/schemas/ReleaseEditResponse'
meta: meta:
$ref: '#/components/schemas/ResponseMeta' $ref: '#/components/schemas/ResponseMeta'
HomeFocusResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/HomeFocusResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
PublishResponseEnvelope: PublishResponseEnvelope:
type: object type: object
additionalProperties: false additionalProperties: false
+32 -2
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Tech Log Studio API title: Tech Log Studio API
version: 3.0.0 version: 3.1.0
description: | description: |
Tech Log Studio orchestration 계약이다. Tech Log Studio orchestration 계약이다.
@@ -1321,7 +1321,10 @@ components:
properties: properties:
type: { type: string, enum: [HEADING] } type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 } id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 } # 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
level: { type: integer, minimum: 1, maximum: 6 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock: ParagraphBlock:
type: object type: object
@@ -1452,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 } width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 } height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean } decorative: { type: boolean }
ThematicBreakBlock:
type: object
additionalProperties: false
description: |
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
required: [type]
properties:
type: { type: string, enum: [THEMATIC_BREAK] }
ImageBlock:
type: object
additionalProperties: false
description: |
`![alt](/media/...)` 로 쓴 그림이다.
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
required: [type, src, alt, title]
properties:
type: { type: string, enum: [IMAGE] }
src: { type: string, minLength: 1, maxLength: 500 }
alt: { type: string, maxLength: 300 }
title: { type: [string, "null"], maxLength: 300 }
CaseRenderBlock: CaseRenderBlock:
oneOf: oneOf:
- { $ref: "#/components/schemas/HeadingBlock" } - { $ref: "#/components/schemas/HeadingBlock" }
@@ -1463,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" } - { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" } - { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" } - { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator: discriminator:
propertyName: type propertyName: type
mapping: mapping:
@@ -1475,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock" DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock" CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel: CasePublicRenderModel:
unevaluatedProperties: false unevaluatedProperties: false
allOf: allOf: