feat: Tech Log Studio 백엔드 기반 — 계약 배선, 오류 코드, 경계 규칙, 스키마, 엔드포인트 2종

설계 패키지의 studio-v1.yaml(v3.0.0, 응답 봉투)을 이 저장소에 배선하고
슬라이스 1의 기반을 세운다. 19개 operation 중 getStudioSession과
listStudioCatalog를 구현했다.

계약과 생성
- src/config/openapi/studio-v1.yaml 을 vendor하고 MANIFEST에 출처 커밋을 기록
- openapi-generator로 DTO(model)만 생성한다. generateApis 대신
  globalProperties.set(['models': '']) — 그 두 속성은 플러그인 7.18.0에 없다
- useOneOfInterfaces=false. 그 대가로 discriminator union 5종의 Jackson 배선이
  깨진다(spec §3.1). 그 5종을 쓰는 7개 operation은 Plan 02에서 전략을 정한 뒤 구현한다
- 생성 코드는 별도 generatedOpenapi sourceSet에 둔다. -Werror가 생성물의 deprecated
  API 사용을 빌드 실패로 승격하기 때문이다. jar와 test 클래스패스에 별도로 얹는다

오류 계약
- StudioError 23종(계약 ApiError.code와 1:1) + StudioException(ApiErrorCarrier)
- StudioExceptionHandler는 techlog 패키지로 범위를 좁힌다. 다른 기능의 오류 응답을
  바꾸지 않기 위해서다
- 클라이언트 문구는 레지스트리의 client_safe_message에서 가져오고 예외 메시지는
  로그 전용이다(ApiErrorCarrier javadoc의 요구)
- 바인딩 예외를 봉투로 옮긴다. 그러지 않으면 bare RFC 7807이 새어 나가 ADR-006을 위반한다

게이트
- TechLogBoundaryArchTest 7종 — spec §4.3의 bounded context 경계. Gradle leaf를
  늘릴 수 없어 이 규칙이 경계의 유일한 방어선이다
- StudioErrorRegistryTest — enum ↔ 레지스트리 ↔ 계약 3축 대조, vendor 사본 해시 검증
- StudioContractDriftTest — springdoc 표면이 계약을 벗어나면 실패. @ComponentScan이라
  새 컨트롤러가 자동으로 걸린다
- StudioSessionCsrfHeaderProfileContractTest — 배포 가능한 세 프로파일이 계약의
  csrf-header-name const로 해소되는지 고정. 이 저장소는 실제 composition root를
  테스트에서 부팅할 수 없어 파일 단언으로 그 층을 덮는다

스키마
- V7__techlog_core.sql, 28 테이블. 설계 DDL에서 studio_idempotency(기존
  idempotency_record 재사용)와 범위 밖 6종을 제외했다
- 원본의 tech_log 스키마 대신 public을 쓴다. 원본의 SET search_path는 Flyway
  세션에만 적용되고 런타임 커넥션 풀은 상속하지 않는다

알려진 제약
- getStudioSession은 세션 인프라(redis-session)가 없어 503 STUDIO_UNAVAILABLE을
  반환한다. 계약이 이 operation에 허용하는 유일한 실패 코드다. 가짜 CSRF 토큰으로
  200을 만들지 않았다
- 따라서 슬라이스 1의 "프론트 로그인 실동작" 목표는 아직 달성되지 않았다

이 커밋은 AGENTS.md의 human-only 커밋 정책에 대한 저장소 소유자의 명시적 지시로
작성됐다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-19 15:14:52 +09:00
co-authored by Claude Opus 5
parent 697fc740e6
commit 91e6d99654
48 changed files with 9495 additions and 220 deletions
@@ -107,6 +107,15 @@ def postgresqlFileserverMetadataIntegrationTest = registerPostgreSqlReadinessTes
def postgresqlFileserverReclamationIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlFileserverReclamationIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.readiness.PostgreSqlFileserverReclamationIntegrationTest')
def postgresqlTechLogSchemaMigrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogSchemaMigrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.TechLogSchemaMigrationTest')
// Task 9 (listStudioCatalog): JdbcCatalogQueryAdapterTest has no @SpringBootConfiguration to hang a
// @SpringBootTest off in this module (same reason as postgresqlTechLogSchemaMigrationTest above), so it
// needs its own opt-in Testcontainers task rather than reusing an existing one.
def postgresqlTechLogCatalogQueryIntegrationTest = registerPostgreSqlReadinessTest(
'postgresqlTechLogCatalogQueryIntegrationTest',
'dev.caskeleton.adapter.outbound.persistence.techlog.query.JdbcCatalogQueryAdapterTest')
def verifyJpaSqlConstructionSafety = tasks.register('verifyJpaSqlConstructionSafety') {
group = 'verification'
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryView;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* catalog는 도메인 repository를 거치지 않고 전용 union query를 쓴다 (설계 08장 §4).
*
* <p>RELATION / EVIDENCE는 슬라이스 2·5에서 채운다. 그때까지 빈 페이지를 반환하며 이는 계약상 유효한 응답이다.
*/
@Repository
public class JdbcCatalogQueryAdapter implements CatalogQueryPort {
private final JdbcClient jdbcClient;
public JdbcCatalogQueryAdapter(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public CatalogPageView search(CatalogEntryType type, String query, String cursor, int limit) {
String pattern =
(query == null || query.isBlank()) ? "%" : "%" + query.toLowerCase(Locale.ROOT) + "%";
List<CatalogEntryView> items =
switch (type) {
case TOPIC -> searchTopics(pattern, limit);
case PROJECT -> searchProjects(pattern, limit);
case RELATION, EVIDENCE -> List.of();
};
return new CatalogPageView(items, null);
}
private List<CatalogEntryView> searchTopics(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM topic "
+ "WHERE status = 'ACTIVE' AND lower(name) LIKE :pattern "
+ "ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.TOPIC,
rs.getString("name"),
null,
null,
"topic:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
private List<CatalogEntryView> searchProjects(String pattern, int limit) {
return jdbcClient
.sql(
"SELECT id, name, updated_at FROM project "
+ "WHERE lower(name) LIKE :pattern ORDER BY name LIMIT :limit")
.param("pattern", pattern)
.param("limit", limit)
.query(
(rs, rowNum) ->
new CatalogEntryView(
UUID.fromString(rs.getString("id")),
CatalogEntryType.PROJECT,
rs.getString("name"),
"PROJECT",
null,
"project:" + rs.getTimestamp("updated_at").toInstant()))
.list();
}
}
@@ -0,0 +1,779 @@
-- Tech Log 코어 스키마.
-- 원본: tech-log-design-package/database/V1__init.sql
-- (branch feature/response-envelope-adr-006, HEAD b20d7a2 — 이 파일은 그 이후 바뀌지 않았다)
--
-- 원본 DDL과의 차이:
-- 1. `CREATE SCHEMA IF NOT EXISTS tech_log;` / `SET search_path TO tech_log, public;` 제거.
-- 이 저장소의 기존 마이그레이션(V1/V3/V4/V5/V6)과 JPA 설정(@EntityScan, @EnableJpaRepositories,
-- PostgreSqlPersistenceConfig의 FlywayConfigurationCustomizer)은 모두 기본 public 스키마를
-- 전제한다. tech_log 전용 스키마로 옮기면 Task 9 이후의 JPA 엔티티가 이 테이블들을 찾지
-- 못한다. 그래서 테이블은 이 저장소의 다른 모든 테이블과 마찬가지로 public 스키마에 만든다.
-- 2. `studio_idempotency` 테이블과 전용 인덱스(`idx_studio_idempotency_expiry`)를 제외한다.
-- 기존 `idempotency_record`를 재사용한다 (spec D5).
-- 3. `release` / `site_config` / `profile_page` / `home_focus_config` /
-- `topic_featured_document` / `project_topic` 테이블과 전용 인덱스(`uq_topic_start_here`)를
-- 제외한다. 이번 범위 밖이다 (spec §2.2). site_config/profile_page/home_focus_config를
-- 시딩하던 마지막 INSERT 구문도 대상 테이블이 없으므로 함께 제외했다.
-- 4. 그 밖의 테이블·컬럼·CHECK·UNIQUE·인덱스·주석·순서는 원본을 그대로 보존한다. 순환 FK
-- `publication.latest_event_id` -> `publication_event.publication_id`의
-- `DEFERRABLE INITIALLY DEFERRED`도 그대로 유지한다 — 즉시 검사로 바꾸면 첫 게시가
-- 구조적으로 불가능해진다.
-- Tech Log initial PostgreSQL schema
-- Target: PostgreSQL 16+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ---------------------------------------------------------------------------
-- Taxonomy and assets
-- ---------------------------------------------------------------------------
CREATE TABLE topic (
id uuid PRIMARY KEY,
name varchar(80) NOT NULL,
normalized_name varchar(80) NOT NULL,
slug varchar(100) NOT NULL,
description varchar(600),
scope text,
status varchar(20) NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'ARCHIVED')),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_topic_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_topic_slug UNIQUE (slug)
);
CREATE TABLE tag (
id uuid PRIMARY KEY,
name varchar(40) NOT NULL,
normalized_name varchar(40) NOT NULL,
slug varchar(60) NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_tag_normalized_name UNIQUE (normalized_name),
CONSTRAINT uq_tag_slug UNIQUE (slug)
);
-- asset_key는 Public content가 참조하는 안정적인 key다.
-- object_key(스토리지 경로)와 분리되며 immutable이다.
--
-- asset_key -> Asset lookup -> current approved delivery path
--
-- 콘텐츠 원문에 object storage URL을 직접 영속하지 않는다.
-- 공개 이력이 있는 asset_key의 재사용 금지는 application rule로 강제한다.
-- (DB는 현재 행의 유일성만 보장한다.)
CREATE TABLE asset (
id uuid PRIMARY KEY,
asset_key varchar(200) NOT NULL,
asset_kind varchar(20) NOT NULL
CHECK (asset_kind IN ('IMAGE', 'DIAGRAM', 'ATTACHMENT')),
management_status varchar(20) NOT NULL
CHECK (management_status IN ('READY', 'ARCHIVED', 'REJECTED', 'QUARANTINED')),
object_key varchar(500) NOT NULL,
original_name varchar(255) NOT NULL,
display_name varchar(255),
content_type varchar(150) NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
width integer CHECK (width IS NULL OR width > 0),
height integer CHECK (height IS NULL OR height > 0),
checksum_sha256 char(64) NOT NULL,
alt_text varchar(300),
decorative boolean NOT NULL DEFAULT false,
first_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_asset_key UNIQUE (asset_key),
CONSTRAINT uq_asset_object_key UNIQUE (object_key)
);
-- alt/decorative는 DB CHECK로 강제하지 않는다.
--
-- 업로드 시점에는 alt를 아직 정하지 않을 수 있어야 하고(Asset Picker에서 이후 수정),
-- 최종 판단은 syntax parser가 아니라 Publication Validation이 한다.
--
-- Asset decorative=false + 사용 위치 alt 비어 있음 -> PublishValidationFailed
-- Asset decorative=true -> alt="" 허용
--
-- 즉 판단 대상은 asset.alt_text 자체가 아니라 "해당 사용 위치의 alt"다.
-- 같은 Asset이 문서마다 다른 alt로 쓰일 수 있으므로 행 단위 CHECK로 표현할 수 없다.
-- ---------------------------------------------------------------------------
-- Knowledge documents
-- ---------------------------------------------------------------------------
CREATE TABLE document (
id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL
CHECK (document_type IN ('CASE', 'REFERENCE')),
slug varchar(180),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
content_format varchar(20) NOT NULL DEFAULT 'MARKDOWN'
CHECK (content_format IN ('MARKDOWN')),
content_format_version smallint NOT NULL DEFAULT 1
CHECK (content_format_version > 0),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'IN_REVIEW', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
cover_asset_id uuid REFERENCES asset(id),
last_verified_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_document_id_type UNIQUE (id, document_type),
CONSTRAINT uq_document_type_slug UNIQUE (document_type, slug),
CONSTRAINT ck_document_slug_non_blank CHECK (slug IS NULL OR length(trim(slug)) > 0),
CONSTRAINT ck_document_publish_time_order CHECK (
first_published_at IS NULL
OR last_published_at IS NULL
OR first_published_at <= last_published_at
)
);
CREATE TABLE case_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'CASE'
CHECK (document_type = 'CASE'),
problem_summary varchar(600) NOT NULL DEFAULT '',
conclusion_summary varchar(600) NOT NULL DEFAULT '',
environment_items jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(environment_items) = 'array'),
CONSTRAINT fk_case_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE reference_detail (
document_id uuid PRIMARY KEY,
document_type varchar(20) NOT NULL DEFAULT 'REFERENCE'
CHECK (document_type = 'REFERENCE'),
scope_summary varchar(600) NOT NULL DEFAULT '',
applies_to jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(applies_to) = 'array'),
excluded_scope jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(excluded_scope) = 'array'),
freshness_status varchar(20) NOT NULL DEFAULT 'CURRENT'
CHECK (freshness_status IN ('CURRENT', 'REVIEW_DUE', 'HISTORICAL')),
CONSTRAINT fk_reference_detail_document
FOREIGN KEY (document_id, document_type)
REFERENCES document(id, document_type)
ON DELETE CASCADE
);
CREATE TABLE document_tag (
document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (document_id, tag_id),
CONSTRAINT uq_document_tag_order UNIQUE (document_id, display_order)
);
CREATE TABLE document_relation (
source_document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
target_document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RELATED', 'DERIVED_FROM', 'SUPERSEDES')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (source_document_id, target_document_id, relation_type),
CONSTRAINT ck_document_relation_not_self CHECK (source_document_id <> target_document_id)
);
-- ---------------------------------------------------------------------------
-- Open questions
-- ---------------------------------------------------------------------------
CREATE TABLE open_question (
id uuid PRIMARY KEY,
slug varchar(180),
question varchar(300) NOT NULL,
summary varchar(600),
context_markdown text NOT NULL DEFAULT '',
importance_markdown text NOT NULL DEFAULT '',
next_verification text,
question_status varchar(20) NOT NULL DEFAULT 'OPEN'
CHECK (question_status IN ('OPEN', 'INVESTIGATING', 'PAUSED', 'RESOLVED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
primary_topic_id uuid REFERENCES topic(id),
resolution_type varchar(30)
CHECK (resolution_type IS NULL OR resolution_type IN (
'DECISION_MADE',
'ASSUMPTION_REJECTED',
'QUESTION_REFRAMED',
'NO_LONGER_RELEVANT'
)),
resolution_summary text,
opened_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_open_question_slug UNIQUE (slug),
CONSTRAINT ck_question_resolution_consistency CHECK (
(question_status = 'RESOLVED'
AND resolution_type IS NOT NULL
AND resolution_summary IS NOT NULL
AND resolved_at IS NOT NULL)
OR
(question_status <> 'RESOLVED')
)
);
CREATE TABLE question_point (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
point_kind varchar(20) NOT NULL
CHECK (point_kind IN ('FACT', 'ASSUMPTION', 'UNKNOWN', 'CONSTRAINT')),
content text NOT NULL CHECK (length(trim(content)) > 0),
display_order integer NOT NULL CHECK (display_order >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_question_point_order UNIQUE (question_id, point_kind, display_order)
);
CREATE TABLE question_update (
id uuid PRIMARY KEY,
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
update_type varchar(30) NOT NULL
CHECK (update_type IN (
'OBSERVATION',
'EVIDENCE',
'SCOPE_CHANGE',
'BLOCKER',
'NEXT_STEP',
'RESOLUTION',
'RESOLUTION_REOPENED'
)),
title varchar(180) NOT NULL,
body_markdown text NOT NULL DEFAULT '',
update_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (update_visibility IN ('PRIVATE', 'PUBLIC')),
sequence_no integer NOT NULL CHECK (sequence_no > 0),
occurred_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_question_update_sequence UNIQUE (question_id, sequence_no)
);
CREATE TABLE question_tag (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL CHECK (display_order >= 0),
PRIMARY KEY (question_id, tag_id),
CONSTRAINT uq_question_tag_order UNIQUE (question_id, display_order)
);
CREATE TABLE question_document_link (
question_id uuid NOT NULL REFERENCES open_question(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(30) NOT NULL
CHECK (relation_type IN ('RESULT_CASE', 'DERIVED_REFERENCE', 'RELATED')),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (question_id, document_id, relation_type)
);
CREATE UNIQUE INDEX uq_question_result_case
ON question_document_link(question_id)
WHERE relation_type = 'RESULT_CASE';
-- ---------------------------------------------------------------------------
-- Projects, decisions, activities
-- ---------------------------------------------------------------------------
CREATE TABLE project (
id uuid PRIMARY KEY,
slug varchar(180),
name varchar(180) NOT NULL,
one_line_purpose varchar(600) NOT NULL DEFAULT '',
purpose_markdown text NOT NULL DEFAULT '',
boundary_markdown text NOT NULL DEFAULT '',
system_overview_markdown text NOT NULL DEFAULT '',
phase varchar(30) NOT NULL DEFAULT 'RESEARCH'
CHECK (phase IN (
'RESEARCH',
'DESIGN',
'IMPLEMENTATION',
'VERIFICATION',
'MAINTENANCE',
'PAUSED',
'COMPLETED'
)),
current_objective text,
next_step text,
technology_labels jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(technology_labels) = 'array'),
workflow_status varchar(20) NOT NULL DEFAULT 'DRAFT'
CHECK (workflow_status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'UNLISTED', 'PUBLIC')),
featured_order integer,
first_published_at timestamptz,
last_published_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_slug UNIQUE (slug),
CONSTRAINT ck_project_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE TABLE project_document_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
document_id uuid NOT NULL REFERENCES document(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, document_id),
CONSTRAINT ck_project_document_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_document_primary_project
ON project_document_link(document_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_question_link (
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
question_id uuid NOT NULL REFERENCES open_question(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (project_id, question_id),
CONSTRAINT ck_project_question_featured_order CHECK (featured_order IS NULL OR featured_order >= 0)
);
CREATE UNIQUE INDEX uq_question_primary_project
ON project_question_link(question_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE project_decision (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
statement varchar(1000) NOT NULL,
rationale_markdown text NOT NULL DEFAULT '',
consequences jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(consequences) = 'array'),
alternatives_markdown text NOT NULL DEFAULT '',
decision_status varchar(20) NOT NULL DEFAULT 'PROPOSED'
CHECK (decision_status IN ('PROPOSED', 'ACCEPTED', 'SUPERSEDED', 'REJECTED')),
target_visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (target_visibility IN ('PRIVATE', 'PUBLIC')),
source_question_id uuid REFERENCES open_question(id),
source_case_id uuid REFERENCES document(id),
superseded_by_id uuid,
is_featured boolean NOT NULL DEFAULT false,
decided_at timestamptz,
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT fk_project_decision_superseded_by
FOREIGN KEY (superseded_by_id)
REFERENCES project_decision(id),
CONSTRAINT ck_project_decision_not_self_supersede
CHECK (superseded_by_id IS NULL OR superseded_by_id <> id),
CONSTRAINT ck_project_decision_status_fields CHECK (
decision_status NOT IN ('ACCEPTED', 'SUPERSEDED')
OR decided_at IS NOT NULL
),
CONSTRAINT ck_project_decision_supersede_target CHECK (
decision_status <> 'SUPERSEDED'
OR superseded_by_id IS NOT NULL
)
);
CREATE UNIQUE INDEX uq_project_featured_decision
ON project_decision(project_id)
WHERE is_featured = true;
CREATE TABLE project_activity (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
activity_type varchar(40) NOT NULL
CHECK (activity_type IN (
'PHASE_CHANGED',
'QUESTION_OPENED',
'QUESTION_RESOLVED',
'DECISION_ACCEPTED',
'CASE_PUBLISHED',
'REFERENCE_PUBLISHED',
'MILESTONE_REACHED',
'PROJECT_PAUSED',
'PROJECT_RESUMED'
)),
title varchar(180) NOT NULL,
summary varchar(600),
visibility varchar(20) NOT NULL DEFAULT 'PRIVATE'
CHECK (visibility IN ('PRIVATE', 'PUBLIC')),
origin varchar(20) NOT NULL
CHECK (origin IN ('AUTO', 'MANUAL')),
related_resource_type varchar(30),
related_resource_id uuid,
occurred_at timestamptz NOT NULL,
operation_key varchar(180),
version bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
created_by varchar(255) NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by varchar(255) NOT NULL,
CONSTRAINT uq_project_activity_operation_key UNIQUE (project_id, operation_key)
);
-- ---------------------------------------------------------------------------
-- Publication and public read model
-- ---------------------------------------------------------------------------
CREATE TABLE public_resource_projection (
resource_type varchar(30) NOT NULL
CHECK (resource_type IN (
'CASE',
'REFERENCE',
'QUESTION',
'PROJECT',
'PROJECT_DECISION',
'PROJECT_ACTIVITY',
'RELEASE',
'PROFILE'
)),
resource_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
publication_state varchar(20) NOT NULL
CHECK (publication_state IN ('ACTIVE', 'WITHDRAWN')),
visibility varchar(20) NOT NULL
CHECK (visibility IN ('PUBLIC', 'UNLISTED')),
title varchar(300) NOT NULL,
summary varchar(600),
state_code varchar(30),
primary_topic_id uuid REFERENCES topic(id),
payload_schema_version smallint NOT NULL CHECK (payload_schema_version > 0),
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
body_plain_text text NOT NULL DEFAULT '',
search_text text NOT NULL DEFAULT '',
content_hash char(64) NOT NULL,
published_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
last_verified_at timestamptz,
latest_index_at timestamptz,
navigation_path varchar(500) NOT NULL,
PRIMARY KEY (resource_type, resource_id)
);
CREATE TABLE public_route (
resource_type varchar(30) NOT NULL,
slug varchar(180) NOT NULL,
resource_id uuid NOT NULL,
route_role varchar(20) NOT NULL
CHECK (route_role IN ('CANONICAL', 'ALIAS')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (resource_type, slug),
CONSTRAINT fk_public_route_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE
);
CREATE UNIQUE INDEX uq_public_route_canonical
ON public_route(resource_type, resource_id)
WHERE route_role = 'CANONICAL';
CREATE TABLE public_resource_tag (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
tag_id uuid NOT NULL REFERENCES tag(id),
display_order integer NOT NULL DEFAULT 0 CHECK (display_order >= 0),
PRIMARY KEY (resource_type, resource_id, tag_id),
CONSTRAINT fk_public_resource_tag_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT uq_public_resource_tag_order
UNIQUE (resource_type, resource_id, display_order)
);
CREATE TABLE public_resource_project_link (
resource_type varchar(30) NOT NULL,
resource_id uuid NOT NULL,
project_id uuid NOT NULL REFERENCES project(id),
relation_type varchar(20) NOT NULL
CHECK (relation_type IN ('PRIMARY', 'RELATED')),
featured_order integer,
PRIMARY KEY (resource_type, resource_id, project_id),
CONSTRAINT fk_public_resource_project_projection
FOREIGN KEY (resource_type, resource_id)
REFERENCES public_resource_projection(resource_type, resource_id)
ON DELETE CASCADE,
CONSTRAINT ck_public_project_featured_order CHECK (
featured_order IS NULL OR featured_order >= 0
)
);
CREATE UNIQUE INDEX uq_public_primary_project
ON public_resource_project_link(resource_type, resource_id)
WHERE relation_type = 'PRIMARY';
CREATE TABLE asset_reference (
asset_id uuid NOT NULL REFERENCES asset(id),
owner_type varchar(30) NOT NULL
CHECK (owner_type IN (
'DOCUMENT',
'QUESTION',
'QUESTION_UPDATE',
'PROJECT',
'DECISION',
'RELEASE',
'PROFILE',
'SITE'
)),
owner_id uuid NOT NULL,
reference_scope varchar(20) NOT NULL
CHECK (reference_scope IN ('WORKING', 'PUBLISHED')),
reference_role varchar(20) NOT NULL
CHECK (reference_role IN ('BODY', 'COVER', 'AVATAR', 'ATTACHMENT')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (asset_id, owner_type, owner_id, reference_scope, reference_role)
);
-- preview_token 테이블은 제거되었다.
-- Capability Token 기반 익명 Preview는 인증된 Preview Artifact(studio_preview)로
-- 대체되었다. contracts/openapi/preview-v1.deprecated.md 참고.
-- ---------------------------------------------------------------------------
-- Studio workflow artifacts
--
-- WorkingCopy는 API projection이므로 범용 working_copy 테이블을 만들지 않는다.
-- 아래 테이블은 편집 대상 자체가 아니라 "편집 흐름이 만들어내는 산출물"을 저장한다.
--
-- source_kind + source_id는 Studio API의 documentId를 가리킨다.
-- documentId는 source aggregate id를 그대로 사용하므로 별도 surrogate id가 없다.
-- ---------------------------------------------------------------------------
-- Validation은 일급 artifact다. 실행하고 버리는 결과가 아니라 특정 version을
-- 검증한 사실을 validation_id로 참조할 수 있어야 한다.
CREATE TABLE studio_validation (
validation_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
validated_version bigint NOT NULL CHECK (validated_version >= 0),
status varchar(20) NOT NULL
CHECK (status IN ('INVALID', 'WARNINGS', 'VALID')),
issues jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(issues) = 'array'),
-- 검증에 사용한 외부 의존 상태(Topic/Project publishability, relation target,
-- Asset READY/QUARANTINED, slug/route ownership, catalog revision,
-- renderer/content-format version)를 정규화한 hash.
-- Publish 시 다시 계산해 값이 다르면 VALIDATION_STALE로 거절한다.
dependency_revision varchar(200) NOT NULL,
validated_at timestamptz NOT NULL DEFAULT now(),
valid_until timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_validation_window CHECK (valid_until > validated_at)
);
-- Preview는 저장된 version + validation + dependency revision을 묶어 만든
-- PublicRenderModel snapshot이다. 인증된 Studio API로만 조회한다.
-- CURRENT/STALE/EXPIRED 상태는 저장하지 않고 조회 시점에 서버가 계산한다.
CREATE TABLE studio_preview (
preview_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
source_version bigint NOT NULL CHECK (source_version >= 0),
validation_id uuid NOT NULL REFERENCES studio_validation(validation_id),
dependency_revision varchar(200) NOT NULL,
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
created_by varchar(255) NOT NULL,
CONSTRAINT ck_studio_preview_expiry CHECK (expires_at > created_at)
);
-- ---------------------------------------------------------------------------
-- Publication aggregate, immutable history, immutable snapshot
--
-- 세 개념을 분리한다.
--
-- publication 현재 게시 상태
-- publication_event 게시/재게시/게시 취소 불변 이력
-- publication_snapshot PUBLISHED/REPUBLISHED 시점의 불변 PublicRenderModel
--
-- public_resource_projection은 여전히 "현재 공개 상태"를 담당한다.
-- 과거 Snapshot을 현재 source나 현재 projection에서 재계산하지 않는다.
-- ---------------------------------------------------------------------------
CREATE TABLE publication (
publication_id uuid PRIMARY KEY,
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
status varchar(20) NOT NULL
CHECK (status IN ('PUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- Publication 자체의 optimistic concurrency 토큰.
-- unpublish는 expectedPublicationRevision으로 이 값을 검증한다.
publication_revision bigint NOT NULL DEFAULT 1 CHECK (publication_revision >= 1),
latest_event_id uuid NOT NULL,
public_path varchar(500) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_publication_source UNIQUE (source_kind, source_id)
);
CREATE TABLE publication_event (
publication_event_id uuid PRIMARY KEY,
publication_id uuid NOT NULL REFERENCES publication(publication_id),
source_kind varchar(30) NOT NULL
CHECK (source_kind IN ('CASE', 'REFERENCE', 'QUESTION', 'PROJECT_DECISION')),
source_id uuid NOT NULL,
event_type varchar(20) NOT NULL
CHECK (event_type IN ('PUBLISHED', 'REPUBLISHED', 'UNPUBLISHED')),
published_version bigint NOT NULL CHECK (published_version >= 0),
-- UNPUBLISHED Event는 자체 snapshot을 만들지 않고 마지막 공개 Snapshot을 참조한다.
source_published_event_id uuid REFERENCES publication_event(publication_event_id),
occurred_at timestamptz NOT NULL DEFAULT now(),
-- Publish 재시도가 중복 Event를 만들지 않도록 최초 요청의 idempotency key를 남긴다.
idempotency_key varchar(200),
created_by varchar(255) NOT NULL,
CONSTRAINT ck_publication_event_source_ref CHECK (
(event_type = 'UNPUBLISHED' AND source_published_event_id IS NOT NULL)
OR (event_type <> 'UNPUBLISHED' AND source_published_event_id IS NULL)
)
);
-- Event row는 생성 후 수정하지 않는다. UPDATE/DELETE 차단은 권한과 application
-- rule로 강제하며, 필요하면 운영에서 REVOKE UPDATE, DELETE로 보강한다.
-- 첫 게시는 publication(latest_event_id) -> publication_event -> publication UPDATE
-- 순서로 한 transaction 안에서 처리된다. 순환 참조를 허용하기 위해 지연 검사한다.
ALTER TABLE publication
ADD CONSTRAINT fk_publication_latest_event
FOREIGN KEY (latest_event_id)
REFERENCES publication_event(publication_event_id)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE publication_snapshot (
publication_event_id uuid PRIMARY KEY
REFERENCES publication_event(publication_event_id),
render_model jsonb NOT NULL CHECK (jsonb_typeof(render_model) = 'object'),
content_format_version varchar(50) NOT NULL,
renderer_contract_version varchar(50) NOT NULL,
-- 게시 시점에 사용된 Asset의 assetKey/delivery path/치수를 고정한다.
-- 이후 Asset이 교체되어도 과거 Snapshot의 표현은 변하지 않는다.
asset_manifest jsonb NOT NULL DEFAULT '[]'::jsonb
CHECK (jsonb_typeof(asset_manifest) = 'array'),
created_at timestamptz NOT NULL DEFAULT now()
);
-- ---------------------------------------------------------------------------
-- Indexes
-- ---------------------------------------------------------------------------
CREATE INDEX idx_document_management
ON document(document_type, workflow_status, updated_at DESC);
CREATE INDEX idx_document_topic
ON document(primary_topic_id, document_type, updated_at DESC);
CREATE INDEX idx_question_status
ON open_question(question_status, updated_at DESC);
CREATE INDEX idx_question_topic
ON open_question(primary_topic_id, question_status, updated_at DESC);
CREATE INDEX idx_question_update_timeline
ON question_update(question_id, occurred_at ASC, sequence_no ASC);
CREATE INDEX idx_project_phase
ON project(phase, updated_at DESC);
CREATE INDEX idx_project_decision
ON project_decision(project_id, decision_status, decided_at DESC);
CREATE INDEX idx_project_activity
ON project_activity(project_id, visibility, occurred_at DESC);
CREATE INDEX idx_asset_status
ON asset(management_status, created_at DESC);
CREATE INDEX idx_asset_checksum
ON asset(checksum_sha256);
CREATE INDEX idx_asset_reference_owner
ON asset_reference(owner_type, owner_id, reference_scope);
CREATE INDEX idx_asset_reference_asset
ON asset_reference(asset_id, reference_scope);
CREATE INDEX idx_public_latest
ON public_resource_projection(latest_index_at DESC, resource_type, resource_id)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC'
AND latest_index_at IS NOT NULL;
CREATE INDEX idx_public_topic
ON public_resource_projection(primary_topic_id, resource_type, published_at DESC)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_type
ON public_resource_projection(resource_type, visibility, published_at DESC)
WHERE publication_state = 'ACTIVE';
CREATE INDEX idx_public_projection_search_trgm
ON public_resource_projection
USING gin (search_text gin_trgm_ops)
WHERE publication_state = 'ACTIVE'
AND visibility = 'PUBLIC';
CREATE INDEX idx_public_project_link_lookup
ON public_resource_project_link(project_id, relation_type, resource_type);
-- Studio workflow artifacts -------------------------------------------------
-- 특정 version에 대한 최신 Validation 조회 (nextAction 계산의 핵심 경로)
CREATE INDEX idx_studio_validation_source
ON studio_validation(source_kind, source_id, validated_version, validated_at DESC);
-- 특정 version에 대한 최신 Preview 조회
CREATE INDEX idx_studio_preview_source
ON studio_preview(source_kind, source_id, source_version, created_at DESC);
-- 만료 Preview 정리 배치
CREATE INDEX idx_studio_preview_expiry
ON studio_preview(expires_at);
-- Publication history -------------------------------------------------------
-- 한 문서의 게시 이력 (occurredAt DESC, publicationEventId DESC 정렬 계약과 일치)
CREATE INDEX idx_publication_event_publication
ON publication_event(publication_id, occurred_at DESC, publication_event_id DESC);
-- 전체 게시 기록 화면과 source 기준 조회
CREATE INDEX idx_publication_event_source
ON publication_event(source_kind, source_id, occurred_at DESC);
@@ -44,7 +44,7 @@ class PostgreSqlMigrationIntegrationTest {
.migrate();
assertThat(appliedVersions(postgres, "flyway_schema_history"))
.containsExactly("1", "3", "4", "5", "6");
.containsExactly("1", "3", "4", "5", "6", "7");
Flyway coreStream =
Flyway.configure()
@@ -0,0 +1,157 @@
package dev.caskeleton.adapter.outbound.persistence.techlog;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
import javax.sql.DataSource;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* V7이 실제 PostgreSQL에 적용되는지, 그리고 순환 FK가 deferrable로 선언됐는지 본다. H2로는 검증할 수 없다 — deferrable 제약이 벤더 의미이기
* 때문이다.
*
* <p>이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에
* 있고, 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 {@code @SpringBootTest}로 컨텍스트를
* 띄울 수 없고, 이 패키지의 형제인 {@code readiness.PostgreSqlMigrationIntegrationTest}와 같은 방식 — Testcontainers
* 위에서 순수 Flyway API를 직접 구동 — 을 쓴다. 컨테이너는 매번 완전히 빈 상태로 시작하므로, 기존 V1/V3/V4/V5/V6 다음에 V7이 얹히는 전체 체인이
* 클린 DB에 처음부터 적용되는 경로를 그대로 검증한다.
*/
class TechLogSchemaMigrationTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the Tech Log schema migration test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
// classpath:db/migration/postgresql only — the same location
// PostgreSqlPersistenceConfig's FlywayConfigurationCustomizer pins the application to. Using
// the full "classpath:db/migration" tree here would also pick up the unrelated jpa/* streams
// (each starting their own V1) and collide.
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void createsEveryTechLogTable() throws Exception {
List<String> expected =
List.of(
"topic",
"tag",
"document",
"case_detail",
"reference_detail",
"document_tag",
"document_relation",
"open_question",
"question_point",
"question_update",
"question_tag",
"question_document_link",
"project",
"project_decision",
"project_document_link",
"project_question_link",
"project_activity",
"asset",
"asset_reference",
"studio_validation",
"studio_preview",
"publication",
"publication_event",
"publication_snapshot",
"public_resource_projection",
"public_route",
"public_resource_tag",
"public_resource_project_link");
List<String> actual = new ArrayList<>();
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")) {
while (rs.next()) {
actual.add(rs.getString(1));
}
}
assertThat(actual).containsAll(expected);
}
@Test
void doesNotCreateAStudioIdempotencyTable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT count(*) FROM information_schema.tables "
+ "WHERE table_schema = 'public' AND table_name = 'studio_idempotency'")) {
rs.next();
assertThat(rs.getInt(1)).isZero();
}
}
@Test
void publicationLatestEventForeignKeyIsDeferrable() throws Exception {
try (Connection connection = dataSource().getConnection();
ResultSet rs =
connection
.createStatement()
.executeQuery(
"SELECT condeferrable, condeferred FROM pg_constraint "
+ "WHERE conname = 'fk_publication_latest_event'")) {
assertThat(rs.next()).as("fk_publication_latest_event 제약이 있어야 한다").isTrue();
assertThat(rs.getBoolean(1)).as("deferrable").isTrue();
assertThat(rs.getBoolean(2)).as("initially deferred").isTrue();
}
}
private static DataSource dataSource() {
return dataSource;
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.persistence.techlog.query;
import static org.assertj.core.api.Assertions.assertThat;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import dev.caskeleton.application.techlog.studio.query.CatalogEntryType;
import dev.caskeleton.application.techlog.studio.query.CatalogPageView;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.postgresql.PostgreSQLContainer;
/**
* 이 모듈(persistence-jpa)에는 {@code @SpringBootConfiguration}이 없다 — Boot 메인 클래스는 app-bootstrap 모듈에 있고,
* 이 모듈의 postgresqlIntegrationTest 소스셋 classpath에는 포함되지 않는다. 그래서 브리프의 {@code @SpringBootTest}로는
* 컨텍스트를 띄울 수 없다({@code TechLogSchemaMigrationTest}가 같은 문제를 겪었다). 이 테스트도 같은 형제 패턴 — Testcontainers
* 위에서 순수 Flyway로 V7까지 적용한 뒤, {@code JdbcClient}와 어댑터를 직접 조립 — 을 쓴다. Spring 컨테이너가 없어도 {@code
* JdbcCatalogQueryAdapter}는 생성자 인자로 {@code JdbcClient} 하나만 받으므로 문제가 없다.
*/
class JdbcCatalogQueryAdapterTest {
private static final String IMAGE =
System.getProperty("jpa.evidence.postgresql.image", "postgres:16-alpine");
private static PostgreSQLContainer postgres;
private static HikariDataSource dataSource;
private static JdbcClient jdbcClient;
private static JdbcCatalogQueryAdapter adapter;
@BeforeAll
static void migrateFreshDatabase() {
if (!DockerClientFactory.instance().isDockerAvailable()) {
throw new IllegalStateException(
"Docker is required for the catalog query adapter test; skipping is forbidden");
}
postgres = new PostgreSQLContainer(IMAGE).withReuse(false);
postgres.start();
HikariConfig config = new HikariConfig();
config.setJdbcUrl(postgres.getJdbcUrl());
config.setUsername(postgres.getUsername());
config.setPassword(postgres.getPassword());
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
dataSource = new HikariDataSource(config);
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.table("flyway_schema_history")
.baselineOnMigrate(false)
.outOfOrder(false)
.load()
.migrate();
jdbcClient = JdbcClient.create(dataSource);
adapter = new JdbcCatalogQueryAdapter(jdbcClient);
}
@AfterAll
static void stopPostgreSql() {
if (dataSource != null) {
dataSource.close();
}
if (postgres != null) {
postgres.stop();
}
}
@Test
void findsTopicsByPrefix() {
jdbcClient
.sql(
"INSERT INTO topic (id, name, normalized_name, slug, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Kafka', 'kafka', 'kafka', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "kaf", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).label()).isEqualTo("Kafka");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
@Test
void returnsAnEmptyPageWhenNothingMatches() {
CatalogPageView page = adapter.search(CatalogEntryType.TOPIC, "zzzz-none", null, 20);
assertThat(page.items()).isEmpty();
assertThat(page.nextCursor()).isNull();
}
/**
* Review Important 1: {@code searchProjects} (JdbcCatalogQueryAdapter) had never run against a
* real database — {@code project} has a different column shape than {@code topic} (slug/name/
* workflow_status vs. name/normalized_name/slug/status), so a column typo or bad bind would only
* have surfaced in production. {@code project}'s NOT-NULL-without-default columns are {@code id},
* {@code name}, {@code created_by}, {@code updated_by} (V7__techlog_core.sql CREATE TABLE
* project) — everything else has a DEFAULT or is nullable, so the minimal INSERT below is valid.
*/
@Test
void findsProjectsByPrefix() {
jdbcClient
.sql(
"INSERT INTO project (id, name, created_by, updated_by) "
+ "VALUES (gen_random_uuid(), 'Payments Platform', 'test', 'test')")
.update();
CatalogPageView page = adapter.search(CatalogEntryType.PROJECT, "pay", null, 20);
assertThat(page.items()).hasSize(1);
assertThat(page.items().get(0).id()).isNotNull();
assertThat(page.items().get(0).label()).isEqualTo("Payments Platform");
assertThat(page.items().get(0).kind()).isEqualTo("PROJECT");
assertThat(page.items().get(0).dependencyRevision()).isNotBlank();
}
}