fix: refuse deletion only while a record is live, and say why

Deleting from Studio was unpredictable: some records went, others answered "in
use", and the reasons did not match what the author saw on screen.

Publication history was the wrong gate. A record that had ever been published
could never be deleted — including one the author had just unpublished, which
is usually the first half of removing it. Now only a record that is currently
published is refused, and an unpublished one takes its history with it. That
history describes what happened to a record; once the record is gone it
describes nothing, and the rows left behind are exactly what crashed the
dashboard and the publication list earlier.

Publishing itself stopped demanding a finished document. A Case required seven
filled fields, so an author with something worth showing could not show it.
Title and slug remain — a record with no title cannot be listed and one with no
path cannot be addressed — and everything else became a warning. The render
models had to move with it: they declared the author's prose non-empty, so
relaxing the validator alone would have turned a friendly warning into a schema
failure at publish time.

Publication rows are removed in one statement rather than in sequence. The
publication and its events point at each other and only one direction is
deferred, so any order leaves a moment where one constraint is broken; inside a
single statement that moment does not exist.
This commit is contained in:
DongHyeonka
2026-08-21 19:28:52 +09:00
parent 8d228255e9
commit 857e6a9c08
8 changed files with 131 additions and 63 deletions
@@ -162,22 +162,50 @@ public class JdbcDocumentDeletionAdapter implements DocumentDeletionPort {
}
@Override
public boolean hasPublicationHistory(String sourceKind, UUID id) {
public boolean isCurrentlyPublished(String sourceKind, UUID id) {
return Boolean.TRUE.equals(
jdbcClient
.sql(
"SELECT EXISTS ("
+ " SELECT 1 FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id"
+ " UNION ALL SELECT 1 FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id"
+ ")")
"SELECT EXISTS (SELECT 1 FROM publication"
+ " WHERE source_kind = :kind AND source_id = :id AND status = 'PUBLISHED')")
.param("kind", sourceKind)
.param("id", id)
.query(Boolean.class)
.single());
}
/**
* 이벤트와 게시를 한 문장으로 지운다.
*
* <p>둘은 서로를 가리키고, 한쪽 방향만 지연 검사다 — 어떤 순서로 나눠 지워도 중간 상태에서 한쪽 제약이 깨진다. 한 문장 안에서는 외래키 검사가 문장 끝에 한 번
* 도는 덕에 그 중간 상태가 존재하지 않는다. 순서를 맞추는 대신 순서가 필요 없게 만든다.
*/
@Override
public int deletePublicationHistory(String sourceKind, UUID id) {
int snapshots =
jdbcClient
.sql(
"DELETE FROM publication_snapshot WHERE publication_event_id IN"
+ " (SELECT publication_event_id FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id)")
.param("kind", sourceKind)
.param("id", id)
.update();
int removed =
jdbcClient
.sql(
"WITH gone_events AS ("
+ " DELETE FROM publication_event"
+ " WHERE source_kind = :kind AND source_id = :id"
+ " RETURNING publication_event_id"
+ ")"
+ " DELETE FROM publication WHERE source_kind = :kind AND source_id = :id")
.param("kind", sourceKind)
.param("id", id)
.update();
return snapshots + removed;
}
/** 미리보기가 검증을 참조하므로 미리보기를 먼저 지운다. */
@Override
public int deleteWorkArtifacts(String sourceKind, UUID id) {
@@ -271,16 +271,36 @@ class ManagementPersistenceIntegrationTest {
}
@Test
void seesPublicationHistoryThroughTheKindAndIdPair() {
// 게시 이력은 문서를 외래키 없이 (source_kind, source_id) 로 가리킨다. 이 쿼리가 없어서
// 지운 문서를 가리키는 이력이 남았고, 대시보드와 게시 기록 화면이 null 을 읽고 죽었다.
void seesOnlyAPublicationThatIsStillLive() {
// 막아야 하는 것은 "게시한 적이 있다" 가 아니라 "지금 읽히고 있다" 다 — 게시를 취소한 기록을
// 영영 지울 수 없게 하면, 작성자가 공개를 취소하는 이유 자체가 막힌다.
UUID id = insertCase("게시된 적 있는 기록");
assertThat(deletion.hasPublicationHistory("CASE", id)).isFalse();
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
insertPublication(id);
assertThat(deletion.isCurrentlyPublished("CASE", id)).isTrue();
assertThat(deletion.isCurrentlyPublished("QUESTION", id)).isFalse();
assertThat(deletion.hasPublicationHistory("CASE", id)).isTrue();
assertThat(deletion.hasPublicationHistory("QUESTION", id)).isFalse();
jdbcClient
.sql("UPDATE publication SET status = 'UNPUBLISHED' WHERE source_id = :id")
.param("id", id)
.update();
assertThat(deletion.isCurrentlyPublished("CASE", id)).isFalse();
}
@Test
void clearsThePublicationHistoryInDependencyOrder() {
// 이력은 기록에 무슨 일이 있었는지 말하는 것이라, 기록이 사라지면 아무것도 가리키지 않는다.
// 외래키가 없어 DB 가 대신 지워 주지 않으므로, 순서까지 여기서 확인한다.
UUID id = insertCase("이력을 남긴 기록");
insertPublication(id);
assertThat(countIn("publication", "source_id", id)).isEqualTo(1);
assertThat(countIn("publication_event", "source_id", id)).isEqualTo(1);
// 반환값은 지운 publication 행 수다 — 이벤트는 같은 문장에서 함께 사라지므로 따로 세지 않는다.
assertThat(deletion.deletePublicationHistory("CASE", id)).isEqualTo(1);
assertThat(countIn("publication", "source_id", id)).isZero();
assertThat(countIn("publication_event", "source_id", id)).isZero();
}
@Test
@@ -46,10 +46,18 @@ public interface DocumentDeletionPort {
boolean questionReferenced(UUID id);
/**
* 게시 이력이 남아 있는지. {@code publication}/{@code publication_event} 는 문서를 외래키 없이 {@code (source_kind,
* source_id)} 로 가리키므로 DB 가 막아 주지 않는다 — 실제로 그래서 지운 문서를 가리키는 이력이 남아 대시보드와 게시 기록 화면이 null 을 읽고 죽었다.
* 지금 공개돼 있는지.
*
* <p>처음에는 "게시한 적이 있는가" 로 막았는데, 그러면 게시를 취소한 기록을 영영 지울 수 없다 — 작성자가 공개를 취소하는 이유가 대개 없애려는 것인데도. 막아야
* 하는 것은 <b>지금 읽히고 있는 것이 소리 없이 사라지는 일</b> 이므로 조건도 그것이다.
*/
boolean hasPublicationHistory(String sourceKind, UUID id);
boolean isCurrentlyPublished(String sourceKind, UUID id);
/**
* 게시 이력을 치운다. 이력은 어떤 기록에 무슨 일이 있었는지 말하는 것이라, 그 기록이 사라지면 아무것도 가리키지 않는다 — 실제로 그렇게 남은 행들이 대시보드와 게시
* 기록 화면을 죽였다. {@code publication} 계열은 외래키가 없어 DB 가 대신 해 주지 않는다.
*/
int deletePublicationHistory(String sourceKind, UUID id);
/** Decision 은 프로젝트에 속한다 — 경로가 둘 다 들고 있으므로 둘로 찾는다. */
record DeletableDecision(UUID id, String decisionStatus, long version) {}
@@ -49,19 +49,15 @@ public class DeleteDocumentDraftUseCase {
() ->
ManagementException.of(
ManagementError.DOCUMENT_NOT_FOUND, "no such working copy"));
// 지금 읽히고 있는 것만 막는다. 세 곳이 공개 여부를 따로 들고 있으므로 셋 다 본다 —
// 어긋난 상태로 지우면 공개 화면이 없는 기록을 가리키게 된다.
if ("PUBLISHED".equals(current.workflowStatus())
|| documents.publiclyProjected(documentType, command.id())) {
|| documents.publiclyProjected(documentType, command.id())
|| documents.isCurrentlyPublished(documentType, command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_PUBLISHED,
"the record is published; unpublish it before deleting");
}
// 게시된 적이 있으면 이력이 남아 있다. 그 이력은 무슨 일이 있었는지에 대한 기록이고,
// 문서만 지우면 아무것도 가리키지 않는 이력이 되어 화면을 깨뜨린다.
if (documents.hasPublicationHistory(documentType, command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_IN_USE,
"the record has publication history; it cannot be deleted");
}
if (documents.documentReferenced(command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_IN_USE,
@@ -71,6 +67,7 @@ public class DeleteDocumentDraftUseCase {
// 대신 해 주지 않고, 남겨 두면 없는 기록을 가리키는 행이 된다.
documents.deleteProjection(documentType, command.id());
documents.deleteWorkArtifacts(documentType, command.id());
documents.deletePublicationHistory(documentType, command.id());
if (documents.deleteDocument(command.id(), command.expectedVersion()) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
@@ -50,10 +50,10 @@ public class DeleteProjectDecisionUseCase {
() ->
ManagementException.of(
ManagementError.DECISION_NOT_FOUND, "no such decision"));
if (documents.hasPublicationHistory("PROJECT_DECISION", decisionId)) {
if (documents.isCurrentlyPublished("PROJECT_DECISION", decisionId)) {
throw ManagementException.of(
ManagementError.DECISION_IN_USE,
"the decision has publication history; it cannot be deleted");
"the decision is published; unpublish it before deleting");
}
if (documents.decisionReferenced(decisionId)) {
throw ManagementException.of(
@@ -62,6 +62,7 @@ public class DeleteProjectDecisionUseCase {
}
documents.deleteProjection("PROJECT_DECISION", decisionId);
documents.deleteWorkArtifacts("PROJECT_DECISION", decisionId);
documents.deletePublicationHistory("PROJECT_DECISION", decisionId);
if (documents.deleteDecision(decisionId, expectedVersion) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
@@ -43,16 +43,12 @@ public class DeleteQuestionUseCase {
() ->
ManagementException.of(
ManagementError.QUESTION_NOT_FOUND, "no such question"));
if (documents.publiclyProjected("QUESTION", command.id())) {
if (documents.publiclyProjected("QUESTION", command.id())
|| documents.isCurrentlyPublished("QUESTION", command.id())) {
throw ManagementException.of(
ManagementError.DOCUMENT_PUBLISHED,
"the question is published; unpublish it before deleting");
}
if (documents.hasPublicationHistory("QUESTION", command.id())) {
throw ManagementException.of(
ManagementError.QUESTION_IN_USE,
"the question has publication history; it cannot be deleted");
}
if (documents.questionReferenced(command.id())) {
throw ManagementException.of(
ManagementError.QUESTION_IN_USE,
@@ -60,6 +56,7 @@ public class DeleteQuestionUseCase {
}
documents.deleteProjection("QUESTION", command.id());
documents.deleteWorkArtifacts("QUESTION", command.id());
documents.deletePublicationHistory("QUESTION", command.id());
if (documents.deleteQuestion(command.id(), command.expectedVersion()) == 0) {
throw ManagementException.withDetails(
ManagementError.VERSION_CONFLICT,
@@ -55,6 +55,15 @@ public final class StudioDocumentValidator {
return new Outcome(status, issues.toList());
}
/**
* 게시를 막는 것은 두 가지뿐이다: 제목과 slug.
*
* <p>예전에는 Case 하나에 일곱 칸을 요구했고, 보여 줄 만한 초안을 가진 작성자가 그것을 보여 줄 수 없었다 — 기록을 남기라고 만든 도구가 기록을 막고 있었다.
* 무엇을 얼마나 쓸지는 작성자가 정하고, 덜 쓴 기록은 덜 쓴 채로 공개된다.
*
* <p>남긴 둘은 취향이 아니라 도달 가능성이다. 제목이 없으면 목록에 실을 수 없고, slug 가 없으면 가리킬 주소가 없다. 그 밖에 오류로 남은 것들은 전부 "가리키는
* 대상이 없다" 는 문제다 — 없는 주제·프로젝트·관계·Asset 을 가리킨 채 공개하면 읽는 쪽에서 깨진다.
*/
private static void validateBase(WorkingCopyBaseInput base, ValidationIssues issues) {
requireText(issues, base.title(), "TITLE_REQUIRED", "/title", "a title is required to publish");
// 렌더 모델의 slug 는 minLength 3 + 패턴이다. 저장은 빈 slug 를 허용하지만 게시는 못 한다.
@@ -63,10 +72,10 @@ public final class StudioDocumentValidator {
} else if (base.slug().length() < 3) {
issues.error("SLUG_TOO_SHORT", "/slug", "a slug must be at least 3 characters");
}
requireText(
warnIfBlank(
issues, base.summary(), "SUMMARY_REQUIRED", "/summary", "a summary is required to publish");
if (base.topicId() == null) {
issues.error("TOPIC_REQUIRED", "/topicId", "a topic is required to publish");
issues.warning("TOPIC_REQUIRED", "/topicId", "a topic is required to publish");
}
}
@@ -85,7 +94,8 @@ public final class StudioDocumentValidator {
== dev.caskeleton.application.techlog.studio.model.RecordKind.PROJECT_DECISION
&& document.base().projectId() == null) {
// 계약: "kind=PROJECT_DECISION 은 게시 시점에 non-null 이어야 한다."
issues.error("PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project");
issues.warning(
"PROJECT_REQUIRED", "/projectId", "a project decision must belong to a project");
}
List<RelationView> relations = document.base().relations();
@@ -112,33 +122,33 @@ public final class StudioDocumentValidator {
private static void validateKindSpecific(WorkingCopyView document, ValidationIssues issues) {
switch (document) {
case WorkingCopyView.CaseWorkingCopyView value -> {
requireText(
warnIfBlank(
issues, value.problem(), "PROBLEM_REQUIRED", "/problem", "a problem is required");
requireText(
warnIfBlank(
issues,
value.conclusion(),
"CONCLUSION_REQUIRED",
"/conclusion",
"a conclusion is required");
if (value.lastVerifiedOn() == null) {
issues.error(
issues.warning(
"LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "a verification date is required");
}
}
case WorkingCopyView.ReferenceWorkingCopyView value -> {
requireText(
warnIfBlank(
issues, value.purpose(), "PURPOSE_REQUIRED", "/purpose", "a purpose is required");
if (value.rules().isEmpty()) {
issues.error("RULES_REQUIRED", "/rules", "at least one rule is required");
issues.warning("RULES_REQUIRED", "/rules", "at least one rule is required");
}
if (value.applyWhen().isEmpty()) {
issues.error(
issues.warning(
"APPLY_WHEN_REQUIRED",
"/applyWhen",
"at least one application condition is required");
}
if (value.verifiedOn() == null) {
issues.error("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required");
issues.warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "a verification date is required");
}
requireOrderedText(issues, value.applyWhen(), "/applyWhen");
requireOrderedText(issues, value.exceptions(), "/exceptions");
@@ -146,12 +156,12 @@ public final class StudioDocumentValidator {
}
case WorkingCopyView.QuestionWorkingCopyView value -> {
if (value.questionStatus() == null) {
issues.error("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required");
issues.warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "a status is required");
}
if (value.facts().isEmpty()) {
issues.error("FACTS_REQUIRED", "/facts", "at least one established fact is required");
issues.warning("FACTS_REQUIRED", "/facts", "at least one established fact is required");
}
requireText(
warnIfBlank(
issues,
value.nextValidation(),
"NEXT_VALIDATION_REQUIRED",
@@ -159,7 +169,7 @@ public final class StudioDocumentValidator {
"the next validation step is required");
if (value.questionStatus() == QuestionStatusView.RESOLVED
&& (value.resolution() == null || isBlank(value.resolution().summary()))) {
issues.error(
issues.warning(
"RESOLUTION_REQUIRED", "/resolution", "a resolved question needs its resolution");
}
requireOrderedText(issues, value.facts(), "/facts");
@@ -169,18 +179,18 @@ public final class StudioDocumentValidator {
}
case WorkingCopyView.ProjectDecisionWorkingCopyView value -> {
if (value.decisionStatus() == null) {
issues.error("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required");
issues.warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "a status is required");
}
if (value.decidedOn() == null) {
issues.error("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required");
issues.warning("DECIDED_ON_REQUIRED", "/decidedOn", "a decision date is required");
}
requireText(
warnIfBlank(
issues,
value.statement(),
"STATEMENT_REQUIRED",
"/statement",
"a statement is required");
requireText(
warnIfBlank(
issues,
value.rationale(),
"RATIONALE_REQUIRED",
@@ -238,6 +248,14 @@ public final class StudioDocumentValidator {
}
}
/** 비어 있음을 경고로 알린다. 게시를 막지는 않는다 — 무엇을 얼마나 쓸지는 작성자가 정한다. */
private static void warnIfBlank(
ValidationIssues issues, String value, String code, String path, String message) {
if (value == null || value.isBlank()) {
issues.warning(code, path, message);
}
}
private static void requireText(
ValidationIssues issues, String value, String code, String path, String message) {
if (isBlank(value)) {
@@ -245,13 +263,12 @@ public final class StudioDocumentValidator {
}
}
/** 계약의 {@code OrderedText.text} 는 minLength 1 이다 — 빈 항목이 있으면 렌더 모델이 계약을 어긴다. */
/** 빈 항목은 빈 항목으로 그려진다. 작성자가 거기 그렇게 둔 것이므로 알리기만 한다. */
private static void requireOrderedText(
ValidationIssues issues, List<OrderedTextView> items, String path) {
for (int i = 0; i < items.size(); i++) {
if (isBlank(items.get(i).text())) {
issues.error(
"ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "an empty item cannot publish");
issues.warning("ORDERED_TEXT_EMPTY", path + "/" + i + "/text", "this item is empty");
}
}
}
+10 -10
View File
@@ -871,7 +871,7 @@ components:
required: [id, text, order]
properties:
id: { type: string, format: uuid }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
ReferenceRule:
type: object
@@ -1242,7 +1242,7 @@ components:
kind: { $ref: "#/components/schemas/RecordKind" }
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
title: { type: string, minLength: 1, maxLength: 120 }
summary: { type: string, minLength: 1, maxLength: 300 }
summary: { type: string, maxLength: 300 }
publicPath: { type: string, minLength: 1, maxLength: 500 }
topic: { $ref: "#/components/schemas/DisplayTarget" }
project:
@@ -1257,7 +1257,7 @@ components:
required: [type, text]
properties:
type: { type: string, enum: [TEXT] }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
InlineContainer:
type: object
required: [type, children]
@@ -1483,8 +1483,8 @@ components:
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
properties:
kind: { type: string, enum: [CASE] }
problem: { type: string, minLength: 1, maxLength: 100000 }
conclusion: { type: string, minLength: 1, maxLength: 100000 }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: string, format: date }
@@ -1497,7 +1497,7 @@ components:
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, minLength: 1, maxLength: 100000 }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -1508,7 +1508,7 @@ components:
additionalProperties: false
required: [summary, evidenceTarget, linkLabel]
properties:
summary: { type: string, minLength: 1, maxLength: 100000 }
summary: { type: string, maxLength: 100000 }
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
linkLabel: { type: string, minLength: 1, maxLength: 120 }
QuestionPublicRenderModel:
@@ -1528,7 +1528,7 @@ components:
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
nextValidation: { type: string, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
@@ -1543,8 +1543,8 @@ components:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
statement: { type: string, minLength: 1, maxLength: 100000 }
rationale: { type: string, minLength: 1, maxLength: 100000 }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
PublicRenderModel:
oneOf: