feat: compose TechLog static and mock adapters
This commit is contained in:
@@ -1,16 +1,23 @@
|
||||
import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts";
|
||||
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "./tech-log/application/tech-log-feature-input.ts";
|
||||
|
||||
type InstalledFeatureInputs = Readonly<
|
||||
Pick<ApplicationFeatureInputs, typeof REFERENCE_FEATURE_ID>
|
||||
Pick<
|
||||
ApplicationFeatureInputs,
|
||||
typeof REFERENCE_FEATURE_ID | typeof TECH_LOG_FEATURE_ID
|
||||
>
|
||||
>;
|
||||
|
||||
export function createInstalledFeatureInputs(
|
||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0],
|
||||
): InstalledFeatureInputs {
|
||||
const referenceFeature = createReferenceFeatureInstalledInput(context);
|
||||
const techLogFeature = createTechLogFeatureInstalledInput();
|
||||
return Object.freeze({
|
||||
[referenceFeature.featureId]: referenceFeature.input,
|
||||
[techLogFeature.featureId]: techLogFeature.input,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
TECH_LOG_FEATURE_ID,
|
||||
type TechLogFeatureInput,
|
||||
} from "../application/tech-log-feature-input.ts";
|
||||
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
||||
import { publicContentQueries } from "./static/public-query.ts";
|
||||
|
||||
export function createTechLogFeatureInstalledInput() {
|
||||
const input: TechLogFeatureInput = Object.freeze({
|
||||
publicContent: publicContentQueries,
|
||||
createStudioGateway: () => createMockStudioGateway(),
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
featureId: TECH_LOG_FEATURE_ID,
|
||||
input,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { ProblemDetails } from "../../contracts/studio/contract.ts";
|
||||
import { stableStringify } from "./stable-stringify.ts";
|
||||
|
||||
export type CursorPayload = { binding: string; lastValue: string; lastId: string };
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function invalid(detail: string) {
|
||||
const problem: ProblemDetails = {
|
||||
type: "https://techlog.local/problems/request-validation-failed", title: "Request validation failed",
|
||||
status: 422, detail, code: "REQUEST_VALIDATION_FAILED", retryable: false,
|
||||
fieldErrors: [{ path: "/cursor", message: detail }],
|
||||
};
|
||||
return new StudioGatewayError(problem);
|
||||
}
|
||||
|
||||
export function cursorBinding(value: unknown) { return stableStringify(value); }
|
||||
|
||||
export function encodeCursor(payload: CursorPayload): string {
|
||||
const bytes = new TextEncoder().encode(stableStringify(payload));
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function decodeCursor(cursor: string, binding: string): CursorPayload {
|
||||
try {
|
||||
const base64 = cursor.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(cursor.length / 4) * 4, "=");
|
||||
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as Partial<CursorPayload>;
|
||||
if (parsed.binding !== binding || typeof parsed.lastValue !== "string" || typeof parsed.lastId !== "string" || !UUID.test(parsed.lastId)) {
|
||||
throw invalid("Cursor does not match the normalized filters and sort.");
|
||||
}
|
||||
return parsed as CursorPayload;
|
||||
} catch (error) {
|
||||
if (error instanceof StudioGatewayError) throw error;
|
||||
throw invalid("Cursor is malformed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { PublicationAggregate, PublicationEvent, PublicationSnapshot, PublicPreview, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||
import { projectWorkingCopy } from "./project-public-render-model.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
const REVISION = "catalog-2026-08-14";
|
||||
|
||||
export const FIXTURE_IDS = {
|
||||
fetchJoinCase: "11111111-1111-4111-8111-111111111111", redisAdapterCase: "11111111-1111-4111-8111-111111111112",
|
||||
stateNonceReference: "11111111-1111-4111-8111-111111111113", edgeTokenQuestion: "11111111-1111-4111-8111-111111111114",
|
||||
conflictCase: "11111111-1111-4111-8111-111111111115", unpublishedReference: "11111111-1111-4111-8111-111111111116",
|
||||
expiredPreviewCase: "11111111-1111-4111-8111-111111111117", topicJpa: "22222222-2222-4222-8222-222222222221",
|
||||
topicRedis: "22222222-2222-4222-8222-222222222222", topicAuthentication: "22222222-2222-4222-8222-222222222223",
|
||||
projectBackend: "33333333-3333-4333-8333-333333333331", projectAuth: "33333333-3333-4333-8333-333333333332",
|
||||
evidenceFetch: "44444444-4444-4444-8444-444444444441", fetchPublication: "55555555-5555-4555-8555-555555555551",
|
||||
redisPublication: "55555555-5555-4555-8555-555555555552", unpublishedPublication: "55555555-5555-4555-8555-555555555553",
|
||||
fetchPublishedEvent: "66666666-6666-4666-8666-666666666661", redisPublishedEvent: "66666666-6666-4666-8666-666666666662",
|
||||
unpublishedSourceEvent: "66666666-6666-4666-8666-666666666663", unpublishedEvent: "66666666-6666-4666-8666-666666666664",
|
||||
fetchValidation: "77777777-7777-4777-8777-777777777771", stateValidation: "77777777-7777-4777-8777-777777777772",
|
||||
edgeValidation: "77777777-7777-4777-8777-777777777773", expiredValidation: "77777777-7777-4777-8777-777777777774",
|
||||
fetchPreview: "88888888-8888-4888-8888-888888888881", expiredPreview: "88888888-8888-4888-8888-888888888882",
|
||||
} as const;
|
||||
|
||||
export const MOCK_CATALOG: CatalogEntry[] = [
|
||||
{ id: FIXTURE_IDS.topicJpa, type: "TOPIC", label: "JPA", publicPath: "/topics/jpa", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.topicRedis, type: "TOPIC", label: "Redis", publicPath: "/topics/redis", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.topicAuthentication, type: "TOPIC", label: "Authentication", publicPath: "/topics/authentication", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.projectBackend, type: "PROJECT", kind: "PROJECT", label: "Backend Skeleton", publicPath: "/projects/backend-skeleton", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.projectAuth, type: "PROJECT", kind: "PROJECT", label: "Auth Lab", publicPath: "/projects/auth-lab", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.fetchJoinCase, type: "RELATION", kind: "CASE", label: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", publicPath: "/cases/collection-fetch-join-pagination", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.redisAdapterCase, type: "RELATION", kind: "CASE", label: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유", publicPath: "/cases/redis-adapter-ttl-boundary", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.stateNonceReference, type: "RELATION", kind: "REFERENCE", label: "Authorization Code Flow에서 state와 nonce의 경계", publicPath: "/references/state-and-nonce-boundary", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.edgeTokenQuestion, type: "RELATION", kind: "QUESTION", label: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?", publicPath: "/questions/validate-edge-token-again", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.evidenceFetch, type: "EVIDENCE", label: "fetch-strategy-boundary", publicPath: "/media/fetch-strategy-boundary.svg", dependencyRevision: REVISION },
|
||||
{ id: FIXTURE_IDS.fetchJoinCase, type: "EVIDENCE", label: "Fetch Join Case", publicPath: "/cases/collection-fetch-join-pagination", dependencyRevision: REVISION },
|
||||
];
|
||||
|
||||
const documents: WorkingCopy[] = [
|
||||
{ id: FIXTURE_IDS.fetchJoinCase, kind: "CASE", version: 8, title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", slug: "collection-fetch-join-pagination", summary: "게시 후 본문 측정값을 보완한 저장본입니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], updatedAt: "2026-08-14T00:55:00.000Z", problem: "DB LIMIT가 사라집니다.", conclusion: "부모를 먼저 페이징합니다.", environment: "PostgreSQL 16 · Hibernate 6", reproduction: "FeedItem 100개", lastVerifiedOn: "2026-08-11", bodyMarkdown: "## 문제를 고정하기 {#fix-the-problem}\n\n반환 행과 페이지 경계를 측정했습니다.\n\n:::evidence key=\"fetch-strategy-boundary\" alt=\"Fetch 전략 비교\" caption=\"페이지 경계\" zoom=\"true\"\n:::" },
|
||||
{ id: FIXTURE_IDS.redisAdapterCase, kind: "CASE", version: 4, title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유", slug: "redis-adapter-ttl-boundary", summary: "만료 정책과 명령 실행 책임을 분리했습니다.", topicId: FIXTURE_IDS.topicRedis, projectId: FIXTURE_IDS.projectBackend, relations: [], updatedAt: "2026-08-07T07:00:00.000Z", problem: "저장 기술이 정책을 소유했습니다.", conclusion: "애플리케이션이 만료 의미를 결정합니다.", environment: "Spring Boot · Redis", reproduction: "계약 테스트", lastVerifiedOn: "2026-08-07", bodyMarkdown: "## 책임 경계\n\n정책과 명령을 분리합니다." },
|
||||
{ id: FIXTURE_IDS.stateNonceReference, kind: "REFERENCE", version: 3, title: "Authorization Code Flow에서 state와 nonce의 경계", slug: "state-and-nonce-boundary", summary: "요청 위조 방지와 Token 재사용 방지를 구분합니다.", topicId: FIXTURE_IDS.topicAuthentication, projectId: FIXTURE_IDS.projectAuth, relations: [], updatedAt: "2026-08-13T23:10:00.000Z", purpose: "각 검증값의 책임을 다시 찾는 기준입니다.", rules: [{ id: "90000000-0000-4000-8000-000000000010", title: "state는 요청을 연결합니다", body: "콜백 값을 비교합니다.", order: 0 }], applyWhen: [{ id: "90000000-0000-4000-8000-000000000011", text: "Code Flow를 구성할 때", order: 0 }], exceptions: [], examples: [{ id: "90000000-0000-4000-8000-000000000012", text: "OIDC 로그인", order: 0 }], verifiedOn: "2026-08-13" },
|
||||
{ id: FIXTURE_IDS.edgeTokenQuestion, kind: "QUESTION", version: 2, title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?", slug: "validate-edge-token-again", summary: "Edge 이후 Spring의 신뢰 경계를 확인합니다.", topicId: FIXTURE_IDS.topicAuthentication, projectId: FIXTURE_IDS.projectAuth, relations: [], updatedAt: "2026-08-13T22:00:00.000Z", questionStatus: "OPEN", facts: [], assumptions: [], unknowns: [{ id: "90000000-0000-4000-8000-000000000020", text: "신뢰 헤더 위조 가능성", order: 0 }], constraints: [], options: [], nextValidation: "위협 모델을 비교합니다.", resolution: null },
|
||||
{ id: FIXTURE_IDS.conflictCase, kind: "CASE", version: 5, title: "저장 충돌 비교 문서", slug: "save-conflict-boundary", summary: "첫 저장에서 서버 최신본을 확인합니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], updatedAt: "2026-08-14T00:20:00.000Z", problem: "동시에 편집했습니다.", conclusion: "원자 필드를 비교합니다.", environment: "Studio", reproduction: "두 탭", lastVerifiedOn: "2026-08-14", bodyMarkdown: "충돌 재현" },
|
||||
{ id: FIXTURE_IDS.unpublishedReference, kind: "REFERENCE", version: 2, title: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준", slug: "jpa-list-fetch-strategy", summary: "조회 전략의 선택 기준입니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], updatedAt: "2026-08-11T23:30:00.000Z", purpose: "목록 조회 기준", rules: [{ id: "90000000-0000-4000-8000-000000000030", title: "경계를 먼저 고정합니다", body: "부모 페이지 경계를 정합니다.", order: 0 }], applyWhen: [{ id: "90000000-0000-4000-8000-000000000031", text: "컬렉션 목록", order: 0 }], exceptions: [], examples: [], verifiedOn: "2026-08-12" },
|
||||
{ id: FIXTURE_IDS.expiredPreviewCase, kind: "CASE", version: 1, title: "만료 미리보기 예시", slug: "expired-preview-example", summary: "고정 시각에서 만료된 Preview입니다.", topicId: FIXTURE_IDS.topicJpa, projectId: FIXTURE_IDS.projectBackend, relations: [], updatedAt: "2026-08-13T23:00:00.000Z", problem: "미리보기가 만료됩니다.", conclusion: "다시 생성합니다.", environment: "Studio", reproduction: "30분 대기", lastVerifiedOn: "2026-08-14", bodyMarkdown: "만료 상태를 확인합니다." },
|
||||
];
|
||||
|
||||
const inputOf = (document: WorkingCopy) => {
|
||||
const { id, version, updatedAt, ...input } = document;
|
||||
void id; void version; void updatedAt;
|
||||
return input;
|
||||
};
|
||||
function report(validationId: string, documentId: string, version: number, status: ValidationReport["status"], validatedAt: string, issues: ValidationReport["issues"] = []): ValidationReport {
|
||||
return { validationId, documentId, validatedVersion: version, status, issues, validatedAt, validUntil: new Date(Date.parse(validatedAt) + 30 * 60_000).toISOString(), dependencyRevision: REVISION };
|
||||
}
|
||||
|
||||
export type MockFixtureSeeds = { documents: WorkingCopy[]; documentVersions: WorkingCopy[]; validations: ValidationReport[]; previews: PublicPreview[]; publications: PublicationAggregate[]; events: PublicationEvent[]; snapshots: PublicationSnapshot[]; catalog: CatalogEntry[]; conflictDocumentIds: string[] };
|
||||
|
||||
export function createFixtureSeeds(): MockFixtureSeeds {
|
||||
const byId = new Map(documents.map((document) => [document.id, document])); const fetch = byId.get(FIXTURE_IDS.fetchJoinCase)!;
|
||||
const fetchV7 = { ...structuredClone(fetch), version: 7, summary: "반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정을 측정하고, 목록의 페이지 경계를 다시 세웠습니다.", updatedAt: "2026-08-14T00:10:00.000Z" } as WorkingCopy;
|
||||
const redis = byId.get(FIXTURE_IDS.redisAdapterCase)!; const expired = byId.get(FIXTURE_IDS.expiredPreviewCase)!; const unpublished = byId.get(FIXTURE_IDS.unpublishedReference)!;
|
||||
const validations = [
|
||||
report(FIXTURE_IDS.fetchValidation, fetch.id, 7, "VALID", "2026-08-14T00:40:00.000Z"),
|
||||
report(FIXTURE_IDS.stateValidation, FIXTURE_IDS.stateNonceReference, 3, "VALID", "2026-08-14T00:40:00.000Z"),
|
||||
report(FIXTURE_IDS.edgeValidation, FIXTURE_IDS.edgeTokenQuestion, 2, "INVALID", "2026-08-14T00:40:00.000Z", [{ code: "QUESTION_FACT_REQUIRED", severity: "ERROR", path: "/facts", message: "사실이 필요합니다." }]),
|
||||
report(FIXTURE_IDS.expiredValidation, expired.id, 1, "VALID", "2026-08-13T23:10:00.000Z"),
|
||||
];
|
||||
const model = (document: WorkingCopy, generatedAt: string) => projectWorkingCopy(inputOf(document), MOCK_CATALOG, { generatedAt, dependencyRevision: REVISION });
|
||||
const fetchPreviewModel = model(fetchV7, "2026-08-14T00:45:00.000Z"); const fetchPublishedModel = model(fetchV7, "2026-08-14T00:50:00.000Z");
|
||||
const redisModel = model(redis, "2026-08-07T07:30:00.000Z"); const unpublishedModel = model(unpublished, "2026-08-12T00:00:00.000Z"); const expiredModel = model(expired, "2026-08-13T23:20:00.000Z");
|
||||
const previews: PublicPreview[] = [
|
||||
{ previewId: FIXTURE_IDS.fetchPreview, documentId: fetch.id, previewVersion: 7, validationId: FIXTURE_IDS.fetchValidation, createdAt: "2026-08-14T00:45:00.000Z", expiresAt: "2026-08-14T01:15:00.000Z", renderModel: fetchPreviewModel },
|
||||
{ previewId: FIXTURE_IDS.expiredPreview, documentId: expired.id, previewVersion: 1, validationId: FIXTURE_IDS.expiredValidation, createdAt: "2026-08-13T23:20:00.000Z", expiresAt: "2026-08-13T23:50:00.000Z", renderModel: expiredModel },
|
||||
];
|
||||
const publications: PublicationAggregate[] = [
|
||||
{ publicationId: FIXTURE_IDS.fetchPublication, documentId: fetch.id, status: "PUBLISHED", publishedVersion: 7, publicationRevision: 1, latestEventId: FIXTURE_IDS.fetchPublishedEvent, publicPath: "/cases/collection-fetch-join-pagination", updatedAt: "2026-08-14T00:50:00.000Z" },
|
||||
{ publicationId: FIXTURE_IDS.redisPublication, documentId: redis.id, status: "PUBLISHED", publishedVersion: 4, publicationRevision: 1, latestEventId: FIXTURE_IDS.redisPublishedEvent, publicPath: "/cases/redis-adapter-ttl-boundary", updatedAt: "2026-08-07T07:30:00.000Z" },
|
||||
{ publicationId: FIXTURE_IDS.unpublishedPublication, documentId: unpublished.id, status: "UNPUBLISHED", publishedVersion: 2, publicationRevision: 2, latestEventId: FIXTURE_IDS.unpublishedEvent, publicPath: "/references/jpa-list-fetch-strategy", updatedAt: "2026-08-13T00:00:00.000Z" },
|
||||
];
|
||||
const events: PublicationEvent[] = [
|
||||
{ publicationEventId: FIXTURE_IDS.fetchPublishedEvent, publicationId: FIXTURE_IDS.fetchPublication, documentId: fetch.id, type: "PUBLISHED", occurredAt: "2026-08-14T00:50:00.000Z", publishedVersion: 7, sourcePublishedEventId: null, snapshotAvailable: true },
|
||||
{ publicationEventId: FIXTURE_IDS.redisPublishedEvent, publicationId: FIXTURE_IDS.redisPublication, documentId: redis.id, type: "PUBLISHED", occurredAt: "2026-08-07T07:30:00.000Z", publishedVersion: 4, sourcePublishedEventId: null, snapshotAvailable: true },
|
||||
{ publicationEventId: FIXTURE_IDS.unpublishedSourceEvent, publicationId: FIXTURE_IDS.unpublishedPublication, documentId: unpublished.id, type: "PUBLISHED", occurredAt: "2026-08-12T00:00:00.000Z", publishedVersion: 2, sourcePublishedEventId: null, snapshotAvailable: true },
|
||||
{ publicationEventId: FIXTURE_IDS.unpublishedEvent, publicationId: FIXTURE_IDS.unpublishedPublication, documentId: unpublished.id, type: "UNPUBLISHED", occurredAt: "2026-08-13T00:00:00.000Z", publishedVersion: 2, sourcePublishedEventId: FIXTURE_IDS.unpublishedSourceEvent, snapshotAvailable: false },
|
||||
];
|
||||
return { documents: structuredClone(documents), documentVersions: structuredClone([...documents, fetchV7]), validations: structuredClone(validations), previews: structuredClone(previews), publications: structuredClone(publications), events: structuredClone(events), snapshots: structuredClone([{ event: events[0], renderModel: fetchPublishedModel }, { event: events[1], renderModel: redisModel }, { event: events[2], renderModel: unpublishedModel }]), catalog: structuredClone(MOCK_CATALOG), conflictDocumentIds: [FIXTURE_IDS.conflictCase] };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { ProblemDetails, PublicationAggregate, PublicationEvent, PublicationSnapshot, PublicPreview, ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||
import { createFixtureSeeds } from "./fixtures.ts";
|
||||
|
||||
export type StoredIdempotencyEntry = {
|
||||
fingerprint: string;
|
||||
outcome: { kind: "success"; value: unknown } | { kind: "problem"; problem: ProblemDetails };
|
||||
};
|
||||
|
||||
export class MockStudioState {
|
||||
readonly documents = new Map<string, WorkingCopy>(); readonly validations = new Map<string, ValidationReport>();
|
||||
readonly previews = new Map<string, PublicPreview>(); readonly publications = new Map<string, PublicationAggregate>();
|
||||
readonly events = new Map<string, PublicationEvent>(); readonly snapshots = new Map<string, PublicationSnapshot>();
|
||||
readonly idempotency = new Map<string, StoredIdempotencyEntry>(); readonly issuedCursors = new Set<string>();
|
||||
readonly pendingConflicts = new Set<string>(); readonly catalog: components["schemas"]["CatalogEntry"][];
|
||||
constructor() {
|
||||
const seeds = createFixtureSeeds();
|
||||
for (const item of seeds.documents) this.documents.set(item.id, structuredClone(item));
|
||||
for (const item of seeds.validations) this.validations.set(item.documentId, structuredClone(item));
|
||||
for (const item of seeds.previews) this.previews.set(item.documentId, structuredClone(item));
|
||||
for (const item of seeds.publications) this.publications.set(item.documentId, structuredClone(item));
|
||||
for (const item of seeds.events) this.events.set(item.publicationEventId, structuredClone(item));
|
||||
for (const item of seeds.snapshots) this.snapshots.set(item.event.publicationEventId, structuredClone(item));
|
||||
this.catalog = structuredClone(seeds.catalog); for (const id of seeds.conflictDocumentIds) this.pendingConflicts.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockStudioState() { return new MockStudioState(); }
|
||||
@@ -0,0 +1,135 @@
|
||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { IdempotentOptions, RequestOptions, StudioGateway } from "../../application/ports/studio-gateway.ts";
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { CatalogPage, DocumentPage, PreviewDetail, ProblemDetails, PublicationAggregate, PublicationEvent, PublicationListItem, PublicationPage, PublicPreview, PublishResult, StudioDashboard, WorkingCopy, WorkingCopyDetail, WorkingCopyInput } from "../../contracts/studio/contract.ts";
|
||||
import { deriveDocumentState, derivePreviewState } from "../../domain/studio/document-state.ts";
|
||||
import { cursorBinding, decodeCursor, encodeCursor } from "./cursor.ts";
|
||||
import { createMockStudioState, MockStudioState } from "./mock-state.ts";
|
||||
import { projectWorkingCopy } from "./project-public-render-model.ts";
|
||||
import { stableStringify } from "./stable-stringify.ts";
|
||||
import { validateWorkingCopy, validateWorkingCopyInputStructure } from "./validate-working-copy.ts";
|
||||
|
||||
export { createMockStudioState } from "./mock-state.ts";
|
||||
|
||||
export type MockStudioDependencies = {
|
||||
clock: { now(): Date };
|
||||
idGenerator: { next(): string };
|
||||
dependencyRevision: { current(): string };
|
||||
};
|
||||
|
||||
export const DEFAULT_STUDIO_MOCK_NOW = "2026-08-14T01:00:00.000Z";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const cp = (value: string) => [...value].length;
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
const normalizeQ = (value?: string) => (value ?? "").trim().replace(/\s+/g, " ").toLocaleLowerCase("ko-KR");
|
||||
|
||||
function defaults(): MockStudioDependencies {
|
||||
let id = 5000;
|
||||
return { clock: { now: () => new Date(DEFAULT_STUDIO_MOCK_NOW) }, idGenerator: { next: () => `aaaaaaaa-aaaa-4aaa-8aaa-${String(id++).padStart(12, "0")}` }, dependencyRevision: { current: () => "catalog-2026-08-14" } };
|
||||
}
|
||||
|
||||
function gatewayProblem(status: number, code: ProblemDetails["code"], detail: string, extras: Partial<ProblemDetails> = {}) {
|
||||
return new StudioGatewayError({ type: `https://techlog.local/problems/${code.toLowerCase().replaceAll("_", "-")}`, title: code, status, detail, code, retryable: false, ...extras });
|
||||
}
|
||||
|
||||
function requestError(fieldErrors: components["schemas"]["FieldError"][]) {
|
||||
return gatewayProblem(422, "REQUEST_VALIDATION_FAILED", "Request fields are invalid.", { fieldErrors });
|
||||
}
|
||||
|
||||
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
const { id, version, updatedAt, ...input } = document;
|
||||
void id; void version; void updatedAt;
|
||||
return input;
|
||||
}
|
||||
|
||||
function limitOf(value?: number) {
|
||||
const limit = value ?? 20;
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw requestError([{ path: "/limit", message: "Limit must be 1-100." }]);
|
||||
return limit;
|
||||
}
|
||||
|
||||
function uuid(value: unknown, path: string): asserts value is string {
|
||||
if (typeof value !== "string" || !UUID.test(value)) throw requestError([{ path, message: "Must be a UUID." }]);
|
||||
}
|
||||
|
||||
function exactSet(left: string[], right: string[]) {
|
||||
return new Set(left).size === left.length && new Set(right).size === right.length && [...left].sort().join("\0") === [...right].sort().join("\0");
|
||||
}
|
||||
|
||||
export function createMockStudioGateway(supplied: Partial<MockStudioDependencies> = {}): StudioGateway {
|
||||
const fallback = defaults();
|
||||
const dependencies: MockStudioDependencies = { clock: supplied.clock ?? fallback.clock, idGenerator: supplied.idGenerator ?? fallback.idGenerator, dependencyRevision: supplied.dependencyRevision ?? fallback.dependencyRevision };
|
||||
const state: MockStudioState = createMockStudioState();
|
||||
|
||||
async function boundary(options?: RequestOptions) {
|
||||
options?.signal?.throwIfAborted(); await Promise.resolve(); options?.signal?.throwIfAborted();
|
||||
}
|
||||
async function read<T>(options: RequestOptions | undefined, work: () => T) { await boundary(options); return clone(work()); }
|
||||
async function idempotent<T>(operation: string, target: string, request: () => unknown, options: IdempotentOptions, work: () => T): Promise<T> {
|
||||
await boundary(options);
|
||||
if (typeof options.idempotencyKey !== "string" || cp(options.idempotencyKey) < 1 || cp(options.idempotencyKey) > 200) throw requestError([{ path: "/idempotencyKey", message: "Idempotency key must be 1-200 characters." }]);
|
||||
const key = `${operation}:${target}:${options.idempotencyKey}`; const fingerprint = stableStringify(request()); const prior = state.idempotency.get(key);
|
||||
if (prior) {
|
||||
if (prior.fingerprint !== fingerprint) throw gatewayProblem(409, "IDEMPOTENCY_KEY_REUSED", "The key was used with a different request.");
|
||||
if (prior.outcome.kind === "problem") throw new StudioGatewayError(clone(prior.outcome.problem));
|
||||
return clone(prior.outcome.value as T);
|
||||
}
|
||||
try { const value = work(); state.idempotency.set(key, { fingerprint, outcome: { kind: "success", value: clone(value) } }); return clone(value); }
|
||||
catch (error) { if (error instanceof StudioGatewayError) { state.idempotency.set(key, { fingerprint, outcome: { kind: "problem", problem: clone(error.problem) } }); throw new StudioGatewayError(clone(error.problem)); } throw error; }
|
||||
}
|
||||
|
||||
const document = (id: string) => { const value = state.documents.get(id); if (!value) throw gatewayProblem(404, "DOCUMENT_NOT_FOUND", `Document ${id} was not found.`); return value; };
|
||||
const detail = (id: string): WorkingCopyDetail => ({ document: document(id), currentValidation: state.validations.get(id) ?? null, latestPreview: state.previews.get(id) ?? null, currentPublication: state.publications.get(id) ?? null, dependencyRevision: dependencies.dependencyRevision.current() });
|
||||
const version = (value: WorkingCopy, expected: number) => { if (value.version !== expected) throw gatewayProblem(409, "VERSION_CONFLICT", `Expected ${expected}; current ${value.version}.`, { latestDocument: clone(detail(value.id)), conflictingFields: [] }); };
|
||||
const structure = (input: WorkingCopyInput) => { const errors = validateWorkingCopyInputStructure(input); if (errors.length) throw requestError(errors); };
|
||||
const materialize = (id: string, value: number, input: WorkingCopyInput): WorkingCopy => ({ ...clone(input), id, version: value, updatedAt: dependencies.clock.now().toISOString(), relations: input.relations.map((relation) => ({ ...relation, id: relation.id ?? dependencies.idGenerator.next(), targetId: relation.targetId! })) }) as WorkingCopy;
|
||||
const summary = (value: WorkingCopy): components["schemas"]["DocumentSummary"] => {
|
||||
const publication = state.publications.get(value.id) ?? null; const project = value.projectId ? state.catalog.find((entry) => entry.id === value.projectId && entry.type === "PROJECT") : undefined;
|
||||
return { id: value.id, title: value.title, kind: value.kind, project: project ? { id: project.id, label: project.label, publicPath: project.publicPath ?? null } : null, updatedAt: value.updatedAt, publicationStatus: publication?.status ?? "NEVER_PUBLISHED", publishedVersion: publication?.publishedVersion ?? null, hasUnpublishedChanges: !publication || publication.publishedVersion !== value.version, nextAction: deriveDocumentState({ ...detail(value.id), now: dependencies.clock.now() }).nextAction };
|
||||
};
|
||||
const publicationRow = (event: PublicationEvent): PublicationListItem => {
|
||||
const publication = [...state.publications.values()].find((item) => item.publicationId === event.publicationId);
|
||||
if (!publication) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found.");
|
||||
const availableActions: components["schemas"]["PublicationAction"][] = event.type === "UNPUBLISHED" ? ["VIEW_SOURCE_SNAPSHOT"] : publication.status === "PUBLISHED" && publication.latestEventId === event.publicationEventId ? ["VIEW_SNAPSHOT", "UNPUBLISH"] : ["VIEW_SNAPSHOT"];
|
||||
return { event, publication, document: summary(document(event.documentId)), availableActions };
|
||||
};
|
||||
const queryText = (q?: unknown) => { if (q !== undefined && (typeof q !== "string" || cp(q) > 100)) throw requestError([{ path: "/q", message: "q must be at most 100 characters." }]); };
|
||||
|
||||
return {
|
||||
getDashboard(options) { return read(options, () => { const all = [...state.documents.values()].map(summary); const readyAll = all.filter((item) => item.nextAction === "PUBLISH"); const events = [...state.events.values()].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); return { continueWriting: all.filter((item) => !["NONE", "PUBLISH"].includes(item.nextAction)).slice(0, 5), readyToPublish: readyAll.slice(0, 5), recentPublications: events.slice(0, 5).map(publicationRow), totals: { documents: all.length, readyToPublish: readyAll.length, publications: events.length } } satisfies StudioDashboard; }); },
|
||||
listDocuments(query, options) { return read(options, () => {
|
||||
queryText(query.q); if (query.projectId !== undefined) uuid(query.projectId, "/projectId"); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), kind: query.kind ?? null, publicationStatus: query.publicationStatus ?? null, nextAction: query.nextAction ?? null, projectId: query.projectId ?? null, sort: query.sort ?? "UPDATED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null;
|
||||
const items = [...state.documents.values()].map(summary).filter((item) => { const source = state.documents.get(item.id)!; return (!normalized.q || `${item.title} ${source.summary} ${source.slug}`.toLocaleLowerCase("ko-KR").includes(normalized.q)) && (!normalized.kind || item.kind === normalized.kind) && (!normalized.publicationStatus || item.publicationStatus === normalized.publicationStatus) && (!normalized.nextAction || item.nextAction === normalized.nextAction) && (!normalized.projectId || source.projectId === normalized.projectId); });
|
||||
items.sort((a, b) => { const primary = normalized.sort === "TITLE_ASC" ? a.title.localeCompare(b.title, "ko") : normalized.sort === "UPDATED_ASC" ? a.updatedAt.localeCompare(b.updatedAt) : b.updatedAt.localeCompare(a.updatedAt); return primary || a.id.localeCompare(b.id); });
|
||||
const page = cursor ? items.filter((item) => { const value = normalized.sort === "TITLE_ASC" ? item.title : item.updatedAt; const order = value.localeCompare(cursor.lastValue, normalized.sort === "TITLE_ASC" ? "ko" : undefined); return normalized.sort === "UPDATED_DESC" ? order < 0 || (order === 0 && item.id > cursor.lastId) : order > 0 || (order === 0 && item.id > cursor.lastId); }) : items; const selected = page.slice(0, limit); const last = selected.at(-1); const lastValue = last ? normalized.sort === "TITLE_ASC" ? last.title : last.updatedAt : "";
|
||||
return { items: selected, nextCursor: selected.length < page.length && last ? encodeCursor({ binding, lastValue, lastId: last.id }) : null } satisfies DocumentPage;
|
||||
}); },
|
||||
createDocument(input, options) { return idempotent("create", "documents", () => input, options, () => { structure(input); const value = materialize(dependencies.idGenerator.next(), 1, input); state.documents.set(value.id, value); return value; }); },
|
||||
getDocument(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); return detail(documentId); }); },
|
||||
saveDocument(documentId, command, options) { return idempotent("save", documentId, () => command, options, () => {
|
||||
uuid(documentId, "/documentId"); structure(command.document); const current = document(documentId); version(current, command.expectedVersion);
|
||||
if (state.pendingConflicts.delete(documentId)) { const latest = materialize(documentId, current.version + 1, { ...inputOf(current), title: "서버에서 먼저 수정된 제목", summary: "서버 최신 요약" }); state.documents.set(documentId, latest); state.validations.delete(documentId); throw gatewayProblem(409, "VERSION_CONFLICT", "The server document changed.", { latestDocument: clone(detail(documentId)), conflictingFields: ["/title", "/summary"] }); }
|
||||
const saved = materialize(documentId, current.version + 1, command.document); state.documents.set(documentId, saved); state.validations.delete(documentId); return detail(documentId);
|
||||
}); },
|
||||
validateDocument(documentId, command, options) { return idempotent("validate", documentId, () => command, options, () => { uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const report = validateWorkingCopy(value, { now: dependencies.clock.now(), validationId: dependencies.idGenerator.next(), dependencyRevision: dependencies.dependencyRevision.current(), catalog: state.catalog, documents: [...state.documents.values()] }); state.validations.set(documentId, report); return report; }); },
|
||||
createPreview(documentId, command, options) { return idempotent("preview", documentId, () => command, options, () => {
|
||||
uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const validation = state.validations.get(documentId); const now = dependencies.clock.now();
|
||||
if (!validation || validation.validationId !== command.validationId || validation.validatedVersion !== value.version || validation.dependencyRevision !== dependencies.dependencyRevision.current() || now.getTime() >= Date.parse(validation.validUntil) || validation.status === "INVALID") throw gatewayProblem(409, "VALIDATION_STALE", "Current validation without errors is required.");
|
||||
const createdAt = now.toISOString(); const preview: PublicPreview = { previewId: dependencies.idGenerator.next(), documentId, previewVersion: value.version, validationId: validation.validationId, createdAt, expiresAt: new Date(now.getTime() + 30 * 60_000).toISOString(), renderModel: projectWorkingCopy(inputOf(value), state.catalog, { generatedAt: createdAt, dependencyRevision: dependencies.dependencyRevision.current() }) }; state.previews.set(documentId, preview); return preview;
|
||||
}); },
|
||||
getCurrentPreview(documentId, options) { return read(options, () => { uuid(documentId, "/documentId"); const value = document(documentId); const preview = state.previews.get(documentId); if (!preview) throw gatewayProblem(404, "PREVIEW_NOT_FOUND", "Preview not found."); const validation = state.validations.get(documentId) ?? null; const result = derivePreviewState({ document: value, validation, preview, publication: state.publications.get(documentId) ?? null, dependencyRevision: dependencies.dependencyRevision.current(), now: dependencies.clock.now() }); return { preview, state: result === "NONE" ? "STALE" : result, currentDocumentVersion: value.version, currentValidationId: validation?.validationId ?? null } satisfies PreviewDetail; }); },
|
||||
publishDocument(documentId, command, options) { return idempotent("publish", documentId, () => ({ ...command, acknowledgedWarningCodes: Array.isArray(command.acknowledgedWarningCodes) ? [...command.acknowledgedWarningCodes].sort() : command.acknowledgedWarningCodes }), options, () => {
|
||||
uuid(documentId, "/documentId"); const value = document(documentId); version(value, command.expectedVersion); const existing = state.publications.get(documentId);
|
||||
if (existing?.status === "PUBLISHED" && existing.publishedVersion === value.version) return { publication: existing, event: state.events.get(existing.latestEventId)! };
|
||||
const now = dependencies.clock.now(); const validation = state.validations.get(documentId); if (!validation || validation.validationId !== command.validationId || validation.validatedVersion !== value.version || validation.dependencyRevision !== dependencies.dependencyRevision.current() || now.getTime() >= Date.parse(validation.validUntil) || validation.status === "INVALID") throw gatewayProblem(409, "VALIDATION_STALE", "Current validation required.");
|
||||
const preview = state.previews.get(documentId); if (!preview || preview.previewId !== command.previewId || preview.previewVersion !== value.version || preview.validationId !== validation.validationId) throw gatewayProblem(409, "PREVIEW_STALE", "Current preview required."); if (now.getTime() >= Date.parse(preview.expiresAt)) throw gatewayProblem(409, "PREVIEW_EXPIRED", "Preview expired.");
|
||||
const warnings = validation.issues.filter((issue) => issue.severity === "WARNING").map((issue) => issue.code); if (!exactSet(warnings, command.acknowledgedWarningCodes)) throw requestError([{ path: "/acknowledgedWarningCodes", message: "Acknowledge all current warnings." }]);
|
||||
const eventId = dependencies.idGenerator.next(); const publicationId = existing?.publicationId ?? dependencies.idGenerator.next(); const event: PublicationEvent = { publicationEventId: eventId, publicationId, documentId, type: existing ? "REPUBLISHED" : "PUBLISHED", occurredAt: now.toISOString(), publishedVersion: value.version, sourcePublishedEventId: null, snapshotAvailable: true }; const publication: PublicationAggregate = { publicationId, documentId, status: "PUBLISHED", publishedVersion: value.version, publicationRevision: (existing?.publicationRevision ?? 0) + 1, latestEventId: eventId, publicPath: preview.renderModel.publicPath, updatedAt: now.toISOString() }; state.events.set(eventId, event); state.publications.set(documentId, publication); state.snapshots.set(eventId, { event: clone(event), renderModel: clone(preview.renderModel) }); return { publication, event } satisfies PublishResult;
|
||||
}); },
|
||||
unpublishPublication(publicationId, command, options) { return idempotent("unpublish", publicationId, () => command, options, () => { uuid(publicationId, "/publicationId"); const current = [...state.publications.values()].find((item) => item.publicationId === publicationId); if (!current) throw gatewayProblem(404, "PUBLICATION_NOT_FOUND", "Publication not found."); if (current.publicationRevision !== command.expectedPublicationRevision) throw gatewayProblem(409, "PUBLICATION_CONFLICT", "Publication revision changed.", { latestPublication: clone(current) }); if (current.status === "UNPUBLISHED") return { publication: current, event: state.events.get(current.latestEventId)! }; const event: PublicationEvent = { publicationEventId: dependencies.idGenerator.next(), publicationId, documentId: current.documentId, type: "UNPUBLISHED", occurredAt: dependencies.clock.now().toISOString(), publishedVersion: current.publishedVersion, sourcePublishedEventId: current.latestEventId, snapshotAvailable: false }; const publication: PublicationAggregate = { ...current, status: "UNPUBLISHED", publicationRevision: current.publicationRevision + 1, latestEventId: event.publicationEventId, updatedAt: event.occurredAt }; state.events.set(event.publicationEventId, event); state.publications.set(current.documentId, publication); return { publication, event }; }); },
|
||||
listPublications(query, options) { return read(options, () => { queryText(query.q); const limit = limitOf(query.limit); const normalized = { q: normalizeQ(query.q), type: query.type ?? null, sort: "OCCURRED_DESC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = [...state.events.values()].filter((event) => (!normalized.type || event.type === normalized.type) && (!normalized.q || `${document(event.documentId).title} ${document(event.documentId).summary}`.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => b.occurredAt.localeCompare(a.occurredAt) || a.publicationEventId.localeCompare(b.publicationEventId)); const source = cursor ? all.filter((event) => event.occurredAt < cursor.lastValue || (event.occurredAt === cursor.lastValue && event.publicationEventId > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected.map(publicationRow), nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.occurredAt, lastId: last.publicationEventId }) : null } satisfies PublicationPage; }); },
|
||||
getPublicationSnapshot(publicationEventId, options) { return read(options, () => { uuid(publicationEventId, "/publicationEventId"); if (!state.events.has(publicationEventId)) throw gatewayProblem(404, "PUBLICATION_EVENT_NOT_FOUND", "Publication event not found."); const snapshot = state.snapshots.get(publicationEventId); if (!snapshot) throw gatewayProblem(404, "PUBLICATION_SNAPSHOT_NOT_FOUND", "Publication snapshot not found."); return snapshot; }); },
|
||||
getCatalog(query, options) { return read(options, () => { if (!query.type) throw requestError([{ path: "/type", message: "type is required." }]); queryText(query.q); const limit = limitOf(query.limit); const normalized = { type: query.type, q: normalizeQ(query.q), sort: "LABEL_ASC" }; const binding = cursorBinding(normalized); const cursor = query.cursor ? decodeCursor(query.cursor, binding) : null; const all = state.catalog.filter((item) => item.type === query.type && (!normalized.q || item.label.toLocaleLowerCase("ko-KR").includes(normalized.q))).sort((a, b) => a.label.localeCompare(b.label, "ko") || a.id.localeCompare(b.id)); const source = cursor ? all.filter((item) => item.label.localeCompare(cursor.lastValue, "ko") > 0 || (item.label === cursor.lastValue && item.id > cursor.lastId)) : all; const selected = source.slice(0, limit); const last = selected.at(-1); return { items: selected, nextCursor: selected.length < source.length && last ? encodeCursor({ binding, lastValue: last.label, lastId: last.id }) : null } satisfies CatalogPage; }); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { projectWorkingCopy as projectWorkingCopyWithEvidence } from "../../domain/content-format/project-public-render-model.ts";
|
||||
import { isSupportedEvidenceKey } from "../static/evidence-assets.ts";
|
||||
|
||||
type ProjectArguments = Parameters<typeof projectWorkingCopyWithEvidence>;
|
||||
|
||||
export function projectWorkingCopy(
|
||||
input: ProjectArguments[0],
|
||||
catalog: ProjectArguments[1],
|
||||
context: ProjectArguments[2],
|
||||
) {
|
||||
return projectWorkingCopyWithEvidence(
|
||||
input,
|
||||
catalog,
|
||||
context,
|
||||
isSupportedEvidenceKey,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
function canonical(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonical);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)]));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(canonical(value));
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type { ValidationReport, WorkingCopy } from "../../contracts/studio/contract.ts";
|
||||
import { parseCaseContent } from "../../domain/content-format/parse-case-content.ts";
|
||||
import { isSupportedEvidenceKey } from "../static/evidence-assets.ts";
|
||||
|
||||
type CatalogEntry = components["schemas"]["CatalogEntry"];
|
||||
type WorkingCopyInput = components["schemas"]["WorkingCopyInput"];
|
||||
type FieldError = components["schemas"]["FieldError"];
|
||||
type ValidationIssue = components["schemas"]["ValidationIssue"];
|
||||
|
||||
export type ValidationDependencies = {
|
||||
now: Date; validationId: string; dependencyRevision: string;
|
||||
catalog: ReadonlyArray<CatalogEntry>; documents: ReadonlyArray<WorkingCopy>;
|
||||
};
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const length = (value: string) => [...value].length;
|
||||
const object = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
const escape = (value: string) => value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
|
||||
function runtimeShapeErrors(value: unknown): FieldError[] {
|
||||
if (!object(value)) return [{ path: "", message: "Document input must be an object." }];
|
||||
const errors: FieldError[] = [];
|
||||
const kind = value.kind;
|
||||
const base = ["kind", "title", "slug", "summary", "topicId", "projectId", "relations"];
|
||||
const branch = kind === "CASE"
|
||||
? ["problem", "conclusion", "environment", "reproduction", "lastVerifiedOn", "bodyMarkdown"]
|
||||
: kind === "REFERENCE"
|
||||
? ["purpose", "rules", "applyWhen", "exceptions", "examples", "verifiedOn"]
|
||||
: kind === "QUESTION"
|
||||
? ["questionStatus", "facts", "assumptions", "unknowns", "constraints", "options", "nextValidation", "resolution"]
|
||||
: [];
|
||||
if (!(["CASE", "REFERENCE", "QUESTION"] as unknown[]).includes(kind)) errors.push({ path: "/kind", message: "Invalid record kind." });
|
||||
const allowed = new Set([...base, ...branch]);
|
||||
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push({ path: `/${escape(key)}`, message: "Additional properties are not allowed." });
|
||||
for (const key of [...base, ...branch]) if (!Object.hasOwn(value, key)) errors.push({ path: `/${key}`, message: "Field is required." });
|
||||
const stringFields = ["title", "slug", "summary", ...(kind === "CASE" ? ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] : []), ...(kind === "REFERENCE" ? ["purpose"] : []), ...(kind === "QUESTION" ? ["nextValidation"] : [])];
|
||||
for (const key of stringFields) if (Object.hasOwn(value, key) && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a string." });
|
||||
for (const key of ["topicId", "projectId"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a UUID or null." });
|
||||
for (const key of ["lastVerifiedOn", "verifiedOn"]) if (Object.hasOwn(value, key) && value[key] !== null && typeof value[key] !== "string") errors.push({ path: `/${key}`, message: "Must be a date or null." });
|
||||
if (kind === "QUESTION" && Object.hasOwn(value, "questionStatus") && ![null, "OPEN", "RESOLVED"].includes(value.questionStatus as never)) errors.push({ path: "/questionStatus", message: "Invalid question status." });
|
||||
|
||||
const checkItem = (item: unknown, path: string, fields: Record<string, "string" | "nullable" | "number">) => {
|
||||
if (!object(item)) { errors.push({ path, message: "Must be an object." }); return; }
|
||||
for (const key of Object.keys(item)) if (!Object.hasOwn(fields, key)) errors.push({ path: `${path}/${escape(key)}`, message: "Additional properties are not allowed." });
|
||||
for (const [key, expected] of Object.entries(fields)) {
|
||||
if (!Object.hasOwn(item, key)) errors.push({ path: `${path}/${key}`, message: "Field is required." });
|
||||
else if (expected === "string" && typeof item[key] !== "string") errors.push({ path: `${path}/${key}`, message: "Must be a string." });
|
||||
else if (expected === "nullable" && item[key] !== null && typeof item[key] !== "string") errors.push({ path: `${path}/${key}`, message: "Must be a string or null." });
|
||||
else if (expected === "number" && typeof item[key] !== "number") errors.push({ path: `${path}/${key}`, message: "Must be a number." });
|
||||
}
|
||||
};
|
||||
const checkArray = (key: string, fields: Record<string, "string" | "nullable" | "number">) => {
|
||||
const array = value[key];
|
||||
if (!Array.isArray(array)) { if (Object.hasOwn(value, key)) errors.push({ path: `/${key}`, message: "Must be an array." }); return; }
|
||||
array.forEach((item, index) => checkItem(item, `/${key}/${index}`, fields));
|
||||
};
|
||||
checkArray("relations", { id: "nullable", targetId: "nullable", reason: "string", order: "number" });
|
||||
if (kind === "REFERENCE") {
|
||||
checkArray("rules", { id: "string", title: "string", body: "string", order: "number" });
|
||||
for (const key of ["applyWhen", "exceptions", "examples"]) checkArray(key, { id: "string", text: "string", order: "number" });
|
||||
}
|
||||
if (kind === "QUESTION") {
|
||||
for (const key of ["facts", "assumptions", "unknowns", "constraints"]) checkArray(key, { id: "string", text: "string", order: "number" });
|
||||
checkArray("options", { id: "string", title: "string", description: "string", order: "number" });
|
||||
if (Object.hasOwn(value, "resolution") && value.resolution !== null) checkItem(value.resolution, "/resolution", { summary: "string", evidenceTargetId: "nullable", linkLabel: "string" });
|
||||
}
|
||||
return errors.filter((entry, index) => errors.findIndex(({ path }) => path === entry.path) === index);
|
||||
}
|
||||
|
||||
function text(errors: FieldError[], path: string, value: string, max: number, min = 0) {
|
||||
if (length(value) < min) errors.push({ path, message: `Must contain at least ${min} character(s).` });
|
||||
else if (length(value) > max) errors.push({ path, message: `Must contain at most ${max} characters.` });
|
||||
}
|
||||
|
||||
function uuid(errors: FieldError[], path: string, value: string | null, nullable = false) {
|
||||
if (nullable && value === null) return;
|
||||
if (value === null || !UUID.test(value)) errors.push({ path, message: "Must be a UUID." });
|
||||
}
|
||||
|
||||
function date(errors: FieldError[], path: string, value: string | null) {
|
||||
if (value === null) return;
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||
if (!match || new Date(Date.UTC(+match[1], +match[2] - 1, +match[3])).toISOString().slice(0, 10) !== value) errors.push({ path, message: "Must be a valid YYYY-MM-DD date." });
|
||||
}
|
||||
|
||||
function ordered(errors: FieldError[], path: string, items: Array<{ id: string; order: number }>, fields: Array<[string, number, number]>) {
|
||||
if (items.length > 50) { errors.push({ path, message: "Must contain at most 50 items." }); return; }
|
||||
const ids = new Set<string>(); const orders = new Set<number>();
|
||||
items.forEach((item, index) => {
|
||||
uuid(errors, `${path}/${index}/id`, item.id);
|
||||
if (ids.has(item.id)) errors.push({ path: `${path}/${index}/id`, message: "IDs must be unique." }); ids.add(item.id);
|
||||
if (!Number.isInteger(item.order) || item.order < 0 || orders.has(item.order)) errors.push({ path: `${path}/${index}/order`, message: "Order must be unique and non-negative." }); orders.add(item.order);
|
||||
for (const [field, max, min] of fields) text(errors, `${path}/${index}/${field}`, String((item as unknown as Record<string, unknown>)[field] ?? ""), max, min);
|
||||
});
|
||||
}
|
||||
|
||||
export function validateWorkingCopyInputStructure(input: WorkingCopyInput): FieldError[] {
|
||||
const shape = runtimeShapeErrors(input); if (shape.length) return shape;
|
||||
const errors: FieldError[] = [];
|
||||
text(errors, "/title", input.title, 120);
|
||||
if (input.slug !== "" && (length(input.slug) < 3 || length(input.slug) > 100 || !SLUG.test(input.slug))) errors.push({ path: "/slug", message: "Invalid slug." });
|
||||
text(errors, "/summary", input.summary, 300); uuid(errors, "/topicId", input.topicId, true); uuid(errors, "/projectId", input.projectId, true);
|
||||
if (input.relations.length > 20) errors.push({ path: "/relations", message: "Must contain at most 20 relations." });
|
||||
else {
|
||||
const ids = new Set<string>(); const targets = new Set<string>(); const orders = new Set<number>();
|
||||
input.relations.forEach((relation, index) => {
|
||||
uuid(errors, `/relations/${index}/id`, relation.id, true); uuid(errors, `/relations/${index}/targetId`, relation.targetId);
|
||||
if (relation.id && ids.has(relation.id)) errors.push({ path: `/relations/${index}/id`, message: "IDs must be unique." }); if (relation.id) ids.add(relation.id);
|
||||
if (relation.targetId && targets.has(relation.targetId)) errors.push({ path: `/relations/${index}/targetId`, message: "Targets must be unique." }); if (relation.targetId) targets.add(relation.targetId);
|
||||
if (!Number.isInteger(relation.order) || relation.order < 0 || orders.has(relation.order)) errors.push({ path: `/relations/${index}/order`, message: "Order must be unique and non-negative." }); orders.add(relation.order);
|
||||
text(errors, `/relations/${index}/reason`, relation.reason, 100_000);
|
||||
});
|
||||
}
|
||||
if (input.kind === "CASE") {
|
||||
for (const field of ["problem", "conclusion", "environment", "reproduction", "bodyMarkdown"] as const) text(errors, `/${field}`, input[field], 100_000);
|
||||
date(errors, "/lastVerifiedOn", input.lastVerifiedOn);
|
||||
} else if (input.kind === "REFERENCE") {
|
||||
text(errors, "/purpose", input.purpose, 100_000);
|
||||
ordered(errors, "/rules", input.rules, [["title", 120, 1], ["body", 100_000, 1]]);
|
||||
for (const [path, items] of [["/applyWhen", input.applyWhen], ["/exceptions", input.exceptions], ["/examples", input.examples]] as const) ordered(errors, path, items, [["text", 100_000, 1]]);
|
||||
date(errors, "/verifiedOn", input.verifiedOn);
|
||||
} else {
|
||||
for (const [path, items] of [["/facts", input.facts], ["/assumptions", input.assumptions], ["/unknowns", input.unknowns], ["/constraints", input.constraints]] as const) ordered(errors, path, items, [["text", 100_000, 1]]);
|
||||
ordered(errors, "/options", input.options, [["title", 120, 1], ["description", 100_000, 0]]);
|
||||
text(errors, "/nextValidation", input.nextValidation, 100_000);
|
||||
if (input.resolution) { text(errors, "/resolution/summary", input.resolution.summary, 100_000); uuid(errors, "/resolution/evidenceTargetId", input.resolution.evidenceTargetId, true); text(errors, "/resolution/linkLabel", input.resolution.linkLabel, 120); }
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
const blank = (value: string) => value.trim().length === 0;
|
||||
|
||||
export function validateWorkingCopy(document: WorkingCopy, dependencies: ValidationDependencies): ValidationReport {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const add = (severity: "ERROR" | "WARNING", code: string, path: string, message: string) => issues.push({ severity, code, path, message });
|
||||
const error = (code: string, path: string, message: string) => add("ERROR", code, path, message);
|
||||
const warning = (code: string, path: string, message: string) => add("WARNING", code, path, message);
|
||||
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
|
||||
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
|
||||
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
|
||||
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
|
||||
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
|
||||
if (!document.projectId) warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다."); else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
|
||||
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
|
||||
if (document.kind === "CASE") {
|
||||
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
|
||||
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
|
||||
else try {
|
||||
for (const block of parseCaseContent(document.bodyMarkdown)) if (block.type === "EVIDENCE_FIGURE") {
|
||||
if (!isSupportedEvidenceKey(block.key)) error("EVIDENCE_UNSUPPORTED", "/bodyMarkdown", `지원하지 않는 Evidence: ${block.key}`);
|
||||
else if (!dependencies.catalog.some((entry) => entry.type === "EVIDENCE" && (entry.id === block.key || entry.label === block.key || entry.publicPath === `/media/${block.key}.svg`))) error("EVIDENCE_NOT_FOUND", "/bodyMarkdown", `Evidence 없음: ${block.key}`);
|
||||
}
|
||||
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
|
||||
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
|
||||
} else if (document.kind === "REFERENCE") {
|
||||
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
|
||||
} else {
|
||||
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
|
||||
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
|
||||
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
|
||||
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
|
||||
}
|
||||
const validatedAt = dependencies.now.toISOString();
|
||||
return { validationId: dependencies.validationId, documentId: document.id, validatedVersion: document.version,
|
||||
status: issues.some(({ severity }) => severity === "ERROR") ? "INVALID" : issues.some(({ severity }) => severity === "WARNING") ? "WARNINGS" : "VALID",
|
||||
issues, validatedAt, validUntil: new Date(dependencies.now.getTime() + 30 * 60_000).toISOString(), dependencyRevision: dependencies.dependencyRevision };
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { projects, publicRecords, releases } from "./public-content.ts";
|
||||
import { getHomeFocusItems, searchPublicContent } from "./public-query.ts";
|
||||
|
||||
export const siteConfig = {
|
||||
brandTitle: "TechLog",
|
||||
identityStatement: "문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.",
|
||||
operator: "동현",
|
||||
contactLabel: "프로필",
|
||||
contactPath: "/profile",
|
||||
latestRelease: "/releases/0.1.0",
|
||||
} as const;
|
||||
|
||||
export type {
|
||||
FocusKey,
|
||||
HomeFocusItem as FocusItem,
|
||||
} from "../../application/ports/public-content-queries.ts";
|
||||
export const focusItems = getHomeFocusItems();
|
||||
|
||||
export type LatestEntry = {
|
||||
id: string;
|
||||
typeLabel: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
date: string;
|
||||
dateTime: string;
|
||||
topic: string;
|
||||
project: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
const publicRecordByPath = new Map(publicRecords.map((record) => [record.path, record]));
|
||||
|
||||
const projectTimeline: ReadonlyArray<LatestEntry> = projects.flatMap((project) =>
|
||||
project.activity.map((activity) => {
|
||||
const record = publicRecordByPath.get(activity.recordPath ?? activity.path);
|
||||
return {
|
||||
id: activity.id,
|
||||
typeLabel:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.kind
|
||||
: "PROJECT ACTIVITY",
|
||||
title:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.title
|
||||
: activity.title,
|
||||
summary: activity.summary,
|
||||
date: activity.date,
|
||||
dateTime: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0],
|
||||
project: project.title,
|
||||
path: activity.path,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const releaseTimeline: ReadonlyArray<LatestEntry> = releases.map((release) => ({
|
||||
id: `release-${release.version}`,
|
||||
typeLabel: "RELEASE",
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
date: release.publishedLabel,
|
||||
dateTime: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
path: release.path,
|
||||
}));
|
||||
|
||||
export const latestEntries: ReadonlyArray<LatestEntry> = [
|
||||
...projectTimeline,
|
||||
...releaseTimeline,
|
||||
].sort((left, right) => right.dateTime.localeCompare(left.dateTime));
|
||||
|
||||
export const exploreEntries = [
|
||||
{
|
||||
label: "Case",
|
||||
description: "문제를 따라가며 검증 과정을 읽습니다",
|
||||
path: "/explore/cases",
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
description: "다시 찾을 수 있는 기술 기준을 확인합니다",
|
||||
path: "/explore/references",
|
||||
},
|
||||
{
|
||||
label: "OpenQuestion",
|
||||
description: "아직 끝나지 않은 판단과 다음 검증을 봅니다",
|
||||
path: "/explore/questions",
|
||||
},
|
||||
{
|
||||
label: "Project",
|
||||
description: "여러 기록을 하나의 시스템 맥락에서 연결합니다",
|
||||
path: "/projects",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const searchEntries = searchPublicContent("");
|
||||
|
||||
const fetchJoinCase = publicRecords.find(
|
||||
(record) =>
|
||||
record.kind === "CASE" && record.slug === "collection-fetch-join-pagination",
|
||||
);
|
||||
|
||||
if (!fetchJoinCase || fetchJoinCase.kind !== "CASE") {
|
||||
throw new Error("Missing canonical Fetch Join Case");
|
||||
}
|
||||
|
||||
export const caseDocument = {
|
||||
title: fetchJoinCase.title,
|
||||
eyebrow: "Case / JPA / Backend Skeleton",
|
||||
summary: fetchJoinCase.summary,
|
||||
problem: fetchJoinCase.problem,
|
||||
conclusion: fetchJoinCase.conclusion,
|
||||
environment: fetchJoinCase.environment,
|
||||
dataset: `Dataset: ${fetchJoinCase.verification}`,
|
||||
dates: `게시 ${fetchJoinCase.publishedLabel} · 마지막 검증 ${fetchJoinCase.lastVerifiedLabel}`,
|
||||
} as const;
|
||||
|
||||
export const documentHeadings = fetchJoinCase.sections.map(({ id, title }) => ({
|
||||
id,
|
||||
label: title,
|
||||
}));
|
||||
|
||||
export const caseSections = fetchJoinCase.sections;
|
||||
|
||||
export const documentRelations = fetchJoinCase.relations;
|
||||
|
||||
export const failedQueryCode = `@Query("""
|
||||
select distinct fi
|
||||
from FeedItem fi
|
||||
join fetch fi.user
|
||||
join fetch fi.page
|
||||
left join fetch fi.highlights h
|
||||
where fi.visibility = :visibility
|
||||
order by fi.firstHighlightedAt desc, fi.id desc
|
||||
""")
|
||||
List<FeedItem> findFeed(
|
||||
@Param("visibility") Visibility visibility,
|
||||
Pageable pageable
|
||||
);`;
|
||||
|
||||
export const splitQueryCode = `Page<FeedItemRow> page = feedItemQuery.findPage(
|
||||
Visibility.PUBLIC,
|
||||
PageRequest.of(0, 20, Sort.by(
|
||||
Sort.Order.desc("firstHighlightedAt"),
|
||||
Sort.Order.desc("id")
|
||||
))
|
||||
);
|
||||
|
||||
List<UUID> feedItemIds = page.getContent().stream()
|
||||
.map(FeedItemRow::id)
|
||||
.toList();
|
||||
|
||||
Map<UUID, List<HighlightRow>> highlights =
|
||||
highlightQuery.findLatestByFeedItemIds(feedItemIds, 3);`;
|
||||
@@ -0,0 +1,720 @@
|
||||
export type RecordKind = "CASE" | "REFERENCE" | "QUESTION";
|
||||
export type QuestionStatus =
|
||||
| "OPEN"
|
||||
| "INVESTIGATING"
|
||||
| "PAUSED"
|
||||
| "RESOLVED"
|
||||
| "ARCHIVED";
|
||||
|
||||
export type PublicRelation = {
|
||||
reason: string;
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type RecordSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
paragraphs: ReadonlyArray<string>;
|
||||
bullets?: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
type PublicRecordBase = {
|
||||
kind: RecordKind;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
path: string;
|
||||
topic: string;
|
||||
topicSlug: string;
|
||||
projectSlug: string;
|
||||
projectTitle: string;
|
||||
publishedAt: string;
|
||||
publishedLabel: string;
|
||||
visibility: "PUBLIC";
|
||||
relations: ReadonlyArray<PublicRelation>;
|
||||
};
|
||||
|
||||
export type CaseRecord = PublicRecordBase & {
|
||||
kind: "CASE";
|
||||
problem: string;
|
||||
conclusion: string;
|
||||
environment: string;
|
||||
verification: string;
|
||||
lastVerifiedLabel: string;
|
||||
sections: ReadonlyArray<RecordSection>;
|
||||
};
|
||||
|
||||
export type ReferenceRule = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type ReferenceRecord = PublicRecordBase & {
|
||||
kind: "REFERENCE";
|
||||
purpose: string;
|
||||
rules: ReadonlyArray<ReferenceRule>;
|
||||
applyWhen: ReadonlyArray<string>;
|
||||
exceptions: ReadonlyArray<string>;
|
||||
examples: ReadonlyArray<string>;
|
||||
verifiedAt: string;
|
||||
};
|
||||
|
||||
export type QuestionOption = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type QuestionRecord = PublicRecordBase & {
|
||||
kind: "QUESTION";
|
||||
questionStatus: QuestionStatus;
|
||||
facts: ReadonlyArray<string>;
|
||||
assumptions: ReadonlyArray<string>;
|
||||
unknowns: ReadonlyArray<string>;
|
||||
constraints: ReadonlyArray<string>;
|
||||
options: ReadonlyArray<QuestionOption>;
|
||||
nextValidation: string;
|
||||
resolution?: {
|
||||
summary: string;
|
||||
path: string;
|
||||
linkLabel: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type PublicRecord = CaseRecord | ReferenceRecord | QuestionRecord;
|
||||
|
||||
export type ProjectDecision = {
|
||||
id: string;
|
||||
status: "ADOPTED" | "PROPOSED";
|
||||
date: string;
|
||||
title: string;
|
||||
statement: string;
|
||||
rationale: string;
|
||||
consequences: ReadonlyArray<string>;
|
||||
evidence: ReadonlyArray<{ title: string; path: string }>;
|
||||
};
|
||||
|
||||
export type ProjectActivity = {
|
||||
id: string;
|
||||
date: string;
|
||||
dateTime: string;
|
||||
type: "PUBLICATION" | "PROJECT UPDATE" | "QUESTION";
|
||||
title: string;
|
||||
summary: string;
|
||||
path: string;
|
||||
recordPath?: string;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
thesis: string;
|
||||
stage: "DESIGN" | "VALIDATION";
|
||||
currentGoal: string;
|
||||
nextStep: string;
|
||||
topics: ReadonlyArray<string>;
|
||||
decisions: ReadonlyArray<ProjectDecision>;
|
||||
activity: ReadonlyArray<ProjectActivity>;
|
||||
};
|
||||
|
||||
export type Release = {
|
||||
version: string;
|
||||
path: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
publishedAt: string;
|
||||
publishedLabel: string;
|
||||
changes: ReadonlyArray<string>;
|
||||
reasons: ReadonlyArray<string>;
|
||||
impacts: ReadonlyArray<string>;
|
||||
related: ReadonlyArray<{ title: string; path: string }>;
|
||||
};
|
||||
|
||||
export type Profile = {
|
||||
name: string;
|
||||
introduction: string;
|
||||
principles: ReadonlyArray<{ title: string; description: string }>;
|
||||
currentProjectSlugs: ReadonlyArray<string>;
|
||||
topics: ReadonlyArray<string>;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type FocusKey = "current" | "question" | "decision";
|
||||
|
||||
export type HomeFocusItem = {
|
||||
key: FocusKey;
|
||||
label: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
details: ReadonlyArray<{ label: string; value: string }>;
|
||||
targetPath: string;
|
||||
};
|
||||
|
||||
export const publicRecords: ReadonlyArray<PublicRecord> = [
|
||||
{
|
||||
kind: "CASE",
|
||||
slug: "collection-fetch-join-pagination",
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
summary:
|
||||
"반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정을 측정하고, 목록의 페이지 경계를 다시 세웠습니다.",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
topic: "JPA",
|
||||
topicSlug: "jpa",
|
||||
projectSlug: "backend-skeleton",
|
||||
projectTitle: "Backend Skeleton",
|
||||
publishedAt: "2026-08-11T14:10:00+09:00",
|
||||
publishedLabel: "2026.08.11",
|
||||
visibility: "PUBLIC",
|
||||
problem:
|
||||
"FeedItem 20건을 요청했지만 컬렉션 Fetch Join 때문에 DB LIMIT가 사라지고, 전체 부모와 자식 행을 읽은 뒤 메모리에서 20건만 남겼습니다.",
|
||||
conclusion:
|
||||
"목록 페이징 쿼리와 컬렉션 로딩을 분리하고, 현재 페이지의 부모 키만 IN 배치로 조회합니다.",
|
||||
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
|
||||
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
|
||||
lastVerifiedLabel: "2026.08.11",
|
||||
sections: [
|
||||
{
|
||||
id: "fix-the-problem",
|
||||
title: "문제를 고정하기",
|
||||
paragraphs: [
|
||||
"피드 목록에는 FeedItem과 작성자, 페이지, 하이라이트, 멘션이 함께 필요했다. 화면은 공개된 FeedItem을 최초 하이라이트 시각의 역순으로 20개씩 보여주고, 각 항목에는 최신 하이라이트를 최대 3개까지 붙인다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "fetch-join-attempt",
|
||||
title: "첫 번째 시도: 컬렉션 Fetch Join",
|
||||
paragraphs: [
|
||||
"실험에 사용한 조회의 핵심 형태는 다음과 같다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "observed-values",
|
||||
title: "관찰한 값",
|
||||
paragraphs: [
|
||||
"테스트 데이터는 FeedItem 100개와 Zipf 형태로 편중된 Highlight·Mention으로 구성했다. 소수의 FeedItem에 자식이 몰리도록 해 평균값만으로 문제가 가려지지 않게 했다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "separate-loading",
|
||||
title: "페이징과 컬렉션 로딩을 분리하기",
|
||||
paragraphs: [
|
||||
"최종 구조는 두 단계다. 먼저 목록 정렬에 필요한 부모를 데이터베이스에서 20개로 고정한다. 그다음 현재 페이지의 부모 ID에 대해서만 필요한 컬렉션을 가져온다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "give-up-one-query",
|
||||
title: "왜 한 번의 쿼리를 포기했는가",
|
||||
paragraphs: [
|
||||
"쿼리 수만 보면 한 번의 Fetch Join이 가장 단순해 보인다. 목록에서는 쿼리 수보다 페이지 경계가 먼저다. 데이터베이스가 20개 부모를 확정하지 못하면 자식 분포가 바뀔 때마다 읽는 행 수와 메모리 사용량이 흔들린다. 반환 개수는 같아도 비용을 예측할 수 없다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "remaining-cost",
|
||||
title: "남은 비용과 적용 범위",
|
||||
paragraphs: [
|
||||
"Batch Fetch는 컬렉션 N+1을 줄이지만 필요한 자식만 자동으로 골라 주지는 않는다. 한 FeedItem에 Highlight가 매우 많다면 현재 페이지의 모든 Highlight가 로드될 수 있다. 최신 3개가 계약이면 부모별 제한 쿼리를 별도로 두어야 한다.",
|
||||
],
|
||||
},
|
||||
],
|
||||
relations: [
|
||||
{
|
||||
reason: "이 질문에서 시작됨",
|
||||
title: "컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가?",
|
||||
path: "/questions/collection-fetch-join-with-pagination",
|
||||
},
|
||||
{
|
||||
reason: "이 Case로 결정함",
|
||||
title: "피드 목록은 부모 페이징과 연관 컬렉션 로딩을 분리합니다.",
|
||||
path: "/projects/backend-skeleton/decisions#feed-pagination-boundary",
|
||||
},
|
||||
{
|
||||
reason: "이 기준 문서로 정리됨",
|
||||
title: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준",
|
||||
path: "/references/jpa-list-fetch-strategy",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "CASE",
|
||||
slug: "redis-adapter-ttl-boundary",
|
||||
title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
|
||||
summary:
|
||||
"만료 정책과 Redis 명령 실행 책임을 분리해 Adapter를 바꿔도 애플리케이션 규칙이 유지되게 했습니다.",
|
||||
path: "/cases/redis-adapter-ttl-boundary",
|
||||
topic: "Redis",
|
||||
topicSlug: "redis",
|
||||
projectSlug: "backend-skeleton",
|
||||
projectTitle: "Backend Skeleton",
|
||||
publishedAt: "2026-08-07T16:30:00+09:00",
|
||||
publishedLabel: "2026.08.07",
|
||||
visibility: "PUBLIC",
|
||||
problem:
|
||||
"Redis Adapter가 키 이름과 TTL까지 결정하면 도메인 규칙이 저장 기술 내부로 들어가 메모리 구현이나 다른 저장소로 교체할 때 같은 정책을 다시 만들어야 했습니다.",
|
||||
conclusion:
|
||||
"애플리케이션은 만료 의미와 시간을 결정하고 Redis Adapter는 전달받은 저장 계약을 Redis 명령으로 변환합니다.",
|
||||
environment: "Spring Boot · Redis · Testcontainers",
|
||||
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
|
||||
lastVerifiedLabel: "2026.08.07",
|
||||
sections: [
|
||||
{
|
||||
id: "ownership",
|
||||
title: "정책과 저장 명령의 주인을 구분하기",
|
||||
paragraphs: [
|
||||
"세션 만료나 임시 링크의 유효 시간은 제품 규칙입니다. EXPIRE 명령을 실행하는 방법은 Redis 구현 세부입니다. 두 책임을 같은 클래스에 두지 않았습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
title: "Port가 표현해야 하는 것",
|
||||
paragraphs: [
|
||||
"Port는 저장할 값과 만료 시각을 입력으로 받습니다. Adapter는 밀리초 변환과 명령 조합을 맡되 기본 TTL을 임의로 보충하지 않습니다.",
|
||||
],
|
||||
},
|
||||
],
|
||||
relations: [
|
||||
{
|
||||
reason: "이 프로젝트에 속함",
|
||||
title: "Backend Skeleton",
|
||||
path: "/projects/backend-skeleton",
|
||||
},
|
||||
{
|
||||
reason: "이 결정과 연결됨",
|
||||
title: "StoragePort와 Adapter의 책임을 분리합니다.",
|
||||
path: "/projects/backend-skeleton/decisions#storage-port-unification",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "REFERENCE",
|
||||
slug: "state-and-nonce-boundary",
|
||||
title: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||
summary:
|
||||
"요청 위조 방지와 ID Token 재사용 방지를 서로 다른 검증값으로 정리했습니다.",
|
||||
path: "/references/state-and-nonce-boundary",
|
||||
topic: "Authentication",
|
||||
topicSlug: "authentication",
|
||||
projectSlug: "auth-lab",
|
||||
projectTitle: "Auth Lab",
|
||||
publishedAt: "2026-08-09T21:20:00+09:00",
|
||||
publishedLabel: "2026.08.09",
|
||||
visibility: "PUBLIC",
|
||||
purpose:
|
||||
"Authorization Code Flow에서 state와 nonce를 같은 보안 값처럼 설명하지 않고 각각 무엇을 검증하는지 다시 찾기 위한 기준입니다.",
|
||||
rules: [
|
||||
{
|
||||
title: "state는 요청과 콜백을 연결합니다",
|
||||
body: "클라이언트가 만든 state를 인증 요청에 넣고 콜백의 값과 비교해 자신이 시작한 흐름인지 확인합니다.",
|
||||
},
|
||||
{
|
||||
title: "nonce는 인증 결과와 ID Token을 연결합니다",
|
||||
body: "OIDC 요청의 nonce를 ID Token의 nonce claim과 비교해 다른 인증 결과가 재사용되는 위험을 줄입니다.",
|
||||
},
|
||||
{
|
||||
title: "PKCE는 Code를 교환할 클라이언트를 증명합니다",
|
||||
body: "code_challenge와 code_verifier는 탈취한 Authorization Code만으로 Token을 교환하지 못하게 합니다.",
|
||||
},
|
||||
],
|
||||
applyWhen: [
|
||||
"브라우저 기반 public client에서 Authorization Code Flow를 구성할 때",
|
||||
"OIDC ID Token을 받아 로그인 결과를 검증할 때",
|
||||
"state, nonce, PKCE의 책임을 설계 문서나 시퀀스에 나눠 표시할 때",
|
||||
],
|
||||
exceptions: [
|
||||
"OAuth만 사용해 ID Token을 받지 않는 흐름에는 nonce 검증 대상이 없습니다.",
|
||||
"state가 있다고 PKCE나 redirect URI 검증을 생략할 수는 없습니다.",
|
||||
],
|
||||
examples: [
|
||||
"SPA는 state와 nonce를 요청별로 생성하고 code_verifier는 브라우저 밖으로 보내지 않습니다.",
|
||||
"서버 기반 클라이언트도 state로 로그인 시작 요청과 콜백을 연결합니다.",
|
||||
],
|
||||
verifiedAt: "2026.08.09",
|
||||
relations: [
|
||||
{
|
||||
reason: "이 질문의 검증 기준",
|
||||
title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?",
|
||||
path: "/questions/validate-edge-token-again",
|
||||
},
|
||||
{
|
||||
reason: "이 프로젝트에 속함",
|
||||
title: "Auth Lab",
|
||||
path: "/projects/auth-lab",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "REFERENCE",
|
||||
slug: "jpa-list-fetch-strategy",
|
||||
title: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준",
|
||||
summary:
|
||||
"ToOne과 컬렉션, 페이지 경계, 전송 행 수를 함께 보고 목록 조회 전략을 고르는 기준입니다.",
|
||||
path: "/references/jpa-list-fetch-strategy",
|
||||
topic: "JPA",
|
||||
topicSlug: "jpa",
|
||||
projectSlug: "backend-skeleton",
|
||||
projectTitle: "Backend Skeleton",
|
||||
publishedAt: "2026-08-10T09:40:00+09:00",
|
||||
publishedLabel: "2026.08.10",
|
||||
visibility: "PUBLIC",
|
||||
purpose:
|
||||
"목록 조회에서 쿼리 수만 줄이다 페이지 경계나 전송량을 잃지 않도록 Fetch 전략을 선택하는 순서를 고정합니다.",
|
||||
rules: [
|
||||
{
|
||||
title: "부모 페이지 경계를 먼저 고정합니다",
|
||||
body: "LIMIT과 정렬이 DB에서 부모 행에 적용되는지 먼저 확인합니다.",
|
||||
},
|
||||
{
|
||||
title: "ToOne과 컬렉션을 분리해 판단합니다",
|
||||
body: "ToOne Fetch Join의 행 증가와 컬렉션 Fetch Join의 중복 행 증가는 비용이 다릅니다.",
|
||||
},
|
||||
{
|
||||
title: "쿼리 수와 로드량을 함께 측정합니다",
|
||||
body: "적은 쿼리가 곧 적은 데이터 로드를 의미하지 않으므로 반환 부모 수, 조인 행 수, 로드 엔티티 수를 함께 기록합니다.",
|
||||
},
|
||||
],
|
||||
applyWhen: [
|
||||
"연관 관계가 포함된 목록에 페이지 나누기가 필요할 때",
|
||||
"N+1을 줄인 뒤 메모리 사용량과 전송 행 수가 늘었을 때",
|
||||
"Fetch Join, Batch Fetch, DTO 조회 중 하나를 선택할 때",
|
||||
],
|
||||
exceptions: [
|
||||
"부모 한 건을 읽는 상세 조회에는 목록 페이지 경계 기준을 그대로 적용하지 않습니다.",
|
||||
"데이터 분포가 크게 치우치면 평균 대신 상위 구간의 로드량도 확인합니다.",
|
||||
],
|
||||
examples: [
|
||||
"부모 ID를 먼저 20개 고른 뒤 해당 ID의 Highlight를 IN 배치로 읽습니다.",
|
||||
"두 개의 List 컬렉션을 동시에 Fetch Join하면 MultipleBagFetchException 가능성을 먼저 확인합니다.",
|
||||
],
|
||||
verifiedAt: "2026.08.11",
|
||||
relations: [
|
||||
{
|
||||
reason: "이 Case에서 측정함",
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
},
|
||||
{
|
||||
reason: "이 질문을 해결함",
|
||||
title: "컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가?",
|
||||
path: "/questions/collection-fetch-join-with-pagination",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "QUESTION",
|
||||
slug: "validate-edge-token-again",
|
||||
title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?",
|
||||
summary:
|
||||
"Edge에서 인증한 뒤 Resource Server가 무엇을 신뢰할지 결정하기 위한 열린 질문입니다.",
|
||||
path: "/questions/validate-edge-token-again",
|
||||
topic: "Authentication",
|
||||
topicSlug: "authentication",
|
||||
projectSlug: "auth-lab",
|
||||
projectTitle: "Auth Lab",
|
||||
publishedAt: "2026-08-08T19:05:00+09:00",
|
||||
publishedLabel: "2026.08.08",
|
||||
visibility: "PUBLIC",
|
||||
questionStatus: "OPEN",
|
||||
facts: [
|
||||
"oauth2-proxy는 Access Token 또는 사용자 식별 헤더를 upstream에 전달할 수 있습니다.",
|
||||
"Spring Resource Server는 발급자와 공개 키를 기준으로 JWT를 다시 검증할 수 있습니다.",
|
||||
],
|
||||
assumptions: [
|
||||
"nginx와 oauth2-proxy, Spring Server는 같은 운영 경계 안에 배치합니다.",
|
||||
],
|
||||
unknowns: [
|
||||
"내부 요청에서 사용자 헤더 위조를 어떤 계층이 차단할지 아직 확정하지 않았습니다.",
|
||||
"각 요청의 JWT 재검증 비용이 실제 병목이 되는지 측정하지 않았습니다.",
|
||||
],
|
||||
constraints: [
|
||||
"브라우저에는 Access Token을 노출하지 않습니다.",
|
||||
"Spring Server가 직접 외부 요청을 받는 우회 경로를 허용하지 않습니다.",
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Resource Server가 JWT를 다시 검증",
|
||||
description: "서비스 경계를 독립적으로 유지하지만 검증 설정과 연산이 중복됩니다.",
|
||||
},
|
||||
{
|
||||
title: "보호된 신뢰 헤더만 사용",
|
||||
description: "구조는 단순하지만 Edge 우회 차단과 헤더 정제 책임을 명확히 증명해야 합니다.",
|
||||
},
|
||||
],
|
||||
nextValidation:
|
||||
"토큰 전달안과 신뢰 헤더안을 위협 모델로 비교하고 Edge 우회 요청을 포함한 통합 테스트를 실행합니다.",
|
||||
relations: [
|
||||
{
|
||||
reason: "검증 기준",
|
||||
title: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||
path: "/references/state-and-nonce-boundary",
|
||||
},
|
||||
{
|
||||
reason: "이 프로젝트에서 추적함",
|
||||
title: "Auth Lab 활동",
|
||||
path: "/projects/auth-lab/activity#edge-trust-boundary",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "QUESTION",
|
||||
slug: "collection-fetch-join-with-pagination",
|
||||
title: "컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가?",
|
||||
summary:
|
||||
"컬렉션을 한 번에 읽으면서 부모 페이지 경계도 DB에 남길 수 있는지 확인한 질문입니다.",
|
||||
path: "/questions/collection-fetch-join-with-pagination",
|
||||
topic: "JPA",
|
||||
topicSlug: "jpa",
|
||||
projectSlug: "backend-skeleton",
|
||||
projectTitle: "Backend Skeleton",
|
||||
publishedAt: "2026-08-05T20:30:00+09:00",
|
||||
publishedLabel: "2026.08.05",
|
||||
visibility: "PUBLIC",
|
||||
questionStatus: "RESOLVED",
|
||||
facts: [
|
||||
"Hibernate는 컬렉션 Fetch Join과 페이지 제한을 함께 사용하면 메모리 페이징 경고를 남깁니다.",
|
||||
"부모 한 건에 자식이 여러 개면 SQL 결과 행에서 부모가 중복됩니다.",
|
||||
],
|
||||
assumptions: [
|
||||
"목록은 부모 FeedItem 20건을 안정적으로 반환해야 합니다.",
|
||||
],
|
||||
unknowns: [],
|
||||
constraints: [
|
||||
"정렬과 LIMIT는 부모 목록 쿼리에서 적용되어야 합니다.",
|
||||
"현재 페이지 밖의 컬렉션을 읽지 않아야 합니다.",
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "컬렉션 Fetch Join 유지",
|
||||
description: "쿼리는 하나지만 DB 페이지 경계를 잃고 전체 조인 결과를 읽습니다.",
|
||||
},
|
||||
{
|
||||
title: "부모 페이징과 컬렉션 조회 분리",
|
||||
description: "쿼리는 늘지만 부모 페이지 경계와 로드 범위를 지킬 수 있습니다.",
|
||||
},
|
||||
],
|
||||
nextValidation: "결정한 분리 조회를 데이터 편중 조건에서도 반복 측정합니다.",
|
||||
resolution: {
|
||||
summary:
|
||||
"컬렉션 Fetch Join을 목록 페이징에서 제거하고 부모 페이지 조회 뒤 현재 ID의 컬렉션만 배치로 읽기로 결정했습니다.",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
linkLabel: "해결 과정을 Case로 읽기",
|
||||
},
|
||||
relations: [
|
||||
{
|
||||
reason: "이 Case로 해결됨",
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
},
|
||||
{
|
||||
reason: "적용 기준",
|
||||
title: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준",
|
||||
path: "/references/jpa-list-fetch-strategy",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const projects: ReadonlyArray<Project> = [
|
||||
{
|
||||
slug: "backend-skeleton",
|
||||
title: "Backend Skeleton",
|
||||
summary:
|
||||
"저장소·Redis·JPA·MongoDB 같은 기술을 붙일 때 애플리케이션 경계를 다시 만들지 않도록 공통 계약을 정리하는 프로젝트입니다.",
|
||||
thesis:
|
||||
"기술별 기능을 많이 제공하는 것보다 교체 가능한 경계와 검증 가능한 계약을 먼저 고정합니다.",
|
||||
stage: "DESIGN",
|
||||
currentGoal: "Filesystem과 Object Storage를 하나의 StoragePort로 통합",
|
||||
nextStep: "MinIO Adapter와 공통 계약 테스트 연결",
|
||||
topics: ["Backend Architecture", "JPA", "Redis", "Storage"],
|
||||
decisions: [
|
||||
{
|
||||
id: "storage-port-unification",
|
||||
status: "ADOPTED",
|
||||
date: "2026.08.10",
|
||||
title: "파일 저장 계약을 하나로 통합합니다",
|
||||
statement:
|
||||
"Filesystem과 Object Storage는 하나의 StoragePort와 서로 다른 Adapter로 구성합니다.",
|
||||
rationale:
|
||||
"애플리케이션이 요구하는 저장 의미는 같고 실제 저장 방식만 달라지므로 호출 계약을 기술별로 나누지 않습니다.",
|
||||
consequences: [
|
||||
"로컬과 MinIO 구현이 같은 계약 테스트를 통과해야 합니다.",
|
||||
"스토리지별 설정과 전송 최적화는 Adapter 내부에 남깁니다.",
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
title: "파일 저장소 계약을 하나로 통합했습니다",
|
||||
path: "/projects/backend-skeleton/activity#storage-contract",
|
||||
},
|
||||
{
|
||||
title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
|
||||
path: "/cases/redis-adapter-ttl-boundary",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "feed-pagination-boundary",
|
||||
status: "ADOPTED",
|
||||
date: "2026.08.11",
|
||||
title: "목록 페이징과 컬렉션 로딩을 분리합니다",
|
||||
statement:
|
||||
"피드 목록은 부모를 먼저 페이징하고 현재 페이지의 연관 컬렉션만 별도 조회합니다.",
|
||||
rationale:
|
||||
"컬렉션 Fetch Join이 DB LIMIT를 제거하고 현재 페이지 밖의 데이터까지 읽는 문제를 피하기 위한 결정입니다.",
|
||||
consequences: [
|
||||
"목록 조회는 한 번의 SQL로 끝나지 않습니다.",
|
||||
"쿼리 수뿐 아니라 조인 행 수와 로드 엔티티 수도 함께 측정합니다.",
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
},
|
||||
{
|
||||
title: "JPA 목록 조회에서 Fetch 전략을 선택하는 기준",
|
||||
path: "/references/jpa-list-fetch-strategy",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: "fetch-join-case-published",
|
||||
date: "2026.08.11",
|
||||
dateTime: "2026-08-11T14:10:00+09:00",
|
||||
type: "PUBLICATION",
|
||||
title: "Fetch Join과 페이징 Case를 게시했습니다",
|
||||
summary: "20건을 요청했지만 DB LIMIT 없이 전체 컬렉션이 로드되는 과정을 측정했습니다.",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
recordPath: "/cases/collection-fetch-join-pagination",
|
||||
},
|
||||
{
|
||||
id: "storage-contract",
|
||||
date: "2026.08.10",
|
||||
dateTime: "2026-08-10T18:40:00+09:00",
|
||||
type: "PROJECT UPDATE",
|
||||
title: "파일 저장소 계약을 하나로 통합했습니다",
|
||||
summary: "Filesystem과 Object Storage를 같은 Port 뒤의 Adapter로 정리했습니다.",
|
||||
path: "/projects/backend-skeleton/activity#storage-contract",
|
||||
},
|
||||
{
|
||||
id: "redis-case-published",
|
||||
date: "2026.08.07",
|
||||
dateTime: "2026-08-07T16:30:00+09:00",
|
||||
type: "PUBLICATION",
|
||||
title: "Redis Adapter 책임 경계 Case를 게시했습니다",
|
||||
summary: "만료 정책과 Redis 명령 실행 책임을 분리해 Adapter 교체 범위를 고정했습니다.",
|
||||
path: "/cases/redis-adapter-ttl-boundary",
|
||||
recordPath: "/cases/redis-adapter-ttl-boundary",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "auth-lab",
|
||||
title: "Auth Lab",
|
||||
summary:
|
||||
"브라우저·Edge·Spring 사이에서 Token과 Session의 책임을 나누고 인증 경계를 검증하는 프로젝트입니다.",
|
||||
thesis:
|
||||
"인증 방식을 이름으로 비교하지 않고 Code 교환, Token 보관, 요청 검증의 실제 주체를 기준으로 나눕니다.",
|
||||
stage: "VALIDATION",
|
||||
currentGoal: "oauth2-proxy 뒤의 Spring 신뢰 경계 결정",
|
||||
nextStep: "JWT 재검증안과 신뢰 헤더안의 위협 모델 비교",
|
||||
topics: ["Authentication", "OAuth 2.0", "OIDC", "Keycloak"],
|
||||
decisions: [
|
||||
{
|
||||
id: "browser-secret-boundary",
|
||||
status: "ADOPTED",
|
||||
date: "2026.08.09",
|
||||
title: "브라우저 클라이언트에는 Client Secret을 두지 않습니다",
|
||||
statement: "SPA는 public client와 Authorization Code Flow + PKCE로 구성합니다.",
|
||||
rationale:
|
||||
"브라우저에 전달된 값은 사용자가 확인할 수 있으므로 Client Secret으로 클라이언트를 증명할 수 없습니다.",
|
||||
consequences: [
|
||||
"Code 교환에는 요청별 code_verifier가 필요합니다.",
|
||||
"state와 nonce는 PKCE와 다른 검증 책임으로 유지합니다.",
|
||||
],
|
||||
evidence: [
|
||||
{
|
||||
title: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||
path: "/references/state-and-nonce-boundary",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: "state-nonce-reference",
|
||||
date: "2026.08.09",
|
||||
dateTime: "2026-08-09T21:20:00+09:00",
|
||||
type: "PUBLICATION",
|
||||
title: "state와 nonce의 경계 Reference를 게시했습니다",
|
||||
summary: "요청 위조 방지와 ID Token 재사용 방지를 서로 다른 검증값으로 정리했습니다.",
|
||||
path: "/references/state-and-nonce-boundary",
|
||||
recordPath: "/references/state-and-nonce-boundary",
|
||||
},
|
||||
{
|
||||
id: "edge-trust-boundary",
|
||||
date: "2026.08.08",
|
||||
dateTime: "2026-08-08T19:05:00+09:00",
|
||||
type: "QUESTION",
|
||||
title: "oauth2-proxy 뒤에서 토큰을 다시 검증할 것인가",
|
||||
summary: "Edge 인증 이후 Spring이 신뢰할 경계를 OpenQuestion으로 분리했습니다.",
|
||||
path: "/projects/auth-lab/activity#edge-trust-boundary",
|
||||
recordPath: "/questions/validate-edge-token-again",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const releases: ReadonlyArray<Release> = [
|
||||
{
|
||||
version: "0.1.0",
|
||||
path: "/releases/0.1.0",
|
||||
title: "TechLog Public·Studio 경계를 확정했습니다",
|
||||
summary:
|
||||
"Public은 불변 게시 Snapshot을 읽고, Studio는 API 계약을 따르는 세션 전용 프론트엔드 시뮬레이션으로 분리했습니다.",
|
||||
publishedAt: "2026-08-06T22:15:00+09:00",
|
||||
publishedLabel: "2026.08.06",
|
||||
changes: [
|
||||
"Public은 게시 이벤트 시점의 불변 Snapshot을 읽고 탐색하도록 분리했습니다.",
|
||||
"Studio는 API 계약을 따르는 세션 전용 프론트엔드 시뮬레이션으로 구성했습니다.",
|
||||
"작업본 저장은 Public을 바꾸지 않으며 게시·재게시·게시 취소는 Studio 세션 안에서만 상태를 갱신합니다.",
|
||||
],
|
||||
reasons: [
|
||||
"작성 중인 내용과 독자가 읽는 문서를 같은 상태로 다루면 저장 실수와 미완성 내용이 외부에 노출될 수 있습니다.",
|
||||
"공개 문서는 관계와 경로까지 함께 검증된 시점의 상태를 유지해야 합니다.",
|
||||
],
|
||||
impacts: [
|
||||
"로그인·데이터베이스·서버 저장 없이 Case·Reference·Question의 작성 흐름을 확인할 수 있습니다.",
|
||||
"새로고침이나 Public 이동 뒤에는 Studio의 세션 Mock 상태가 초기화됩니다.",
|
||||
"Studio의 Mock 게시·게시 취소는 기존 Public 콘텐츠와 검색 결과를 바꾸지 않습니다.",
|
||||
],
|
||||
related: [
|
||||
{ title: "TechLog 홈", path: "/" },
|
||||
{ title: "공개 기록 탐색", path: "/explore" },
|
||||
{
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const profile: Profile = {
|
||||
name: "동현",
|
||||
introduction: "문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.",
|
||||
principles: [
|
||||
{
|
||||
title: "관찰한 사실과 판단을 나눕니다",
|
||||
description: "측정값, 문서 근거, 아직 확인하지 못한 가정을 같은 문장에 섞지 않습니다.",
|
||||
},
|
||||
{
|
||||
title: "결론보다 경계를 남깁니다",
|
||||
description: "어떤 조건에서 선택했고 어디까지 적용할 수 있는지 함께 기록합니다.",
|
||||
},
|
||||
{
|
||||
title: "프로젝트 맥락으로 다시 연결합니다",
|
||||
description: "Case와 Reference, Question, Decision이 따로 흩어지지 않게 실제 작업과 연결합니다.",
|
||||
},
|
||||
],
|
||||
currentProjectSlugs: ["backend-skeleton", "auth-lab"],
|
||||
topics: ["Backend Architecture", "JPA", "Authentication", "Redis"],
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
projects,
|
||||
publicRecords,
|
||||
releases,
|
||||
type Project,
|
||||
type ProjectActivity,
|
||||
type ProjectDecision,
|
||||
type PublicRecord,
|
||||
type RecordKind,
|
||||
type Release,
|
||||
type HomeFocusItem,
|
||||
} from "./public-content.ts";
|
||||
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
||||
|
||||
export type RecordFilters = {
|
||||
kind?: RecordKind;
|
||||
topic?: string;
|
||||
project?: string;
|
||||
openQuestionsOnly?: boolean;
|
||||
};
|
||||
|
||||
export type SearchablePublicEntity = {
|
||||
contentType: RecordKind | "PROJECT" | "RELEASE";
|
||||
title: string;
|
||||
summary: string;
|
||||
path: string;
|
||||
topic?: string;
|
||||
topics?: ReadonlyArray<string>;
|
||||
project?: string;
|
||||
publishedAt?: string;
|
||||
};
|
||||
|
||||
function comparePublishedAt(
|
||||
left: Pick<PublicRecord, "publishedAt">,
|
||||
right: Pick<PublicRecord, "publishedAt">,
|
||||
) {
|
||||
return right.publishedAt.localeCompare(left.publishedAt);
|
||||
}
|
||||
|
||||
export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
|
||||
const hasTopicFilter = filters.topic !== undefined;
|
||||
const hasProjectFilter = filters.project !== undefined;
|
||||
const requestedTopic = filters.topic?.trim().toLocaleLowerCase("ko-KR");
|
||||
const requestedProject = filters.project
|
||||
?.trim()
|
||||
.toLocaleLowerCase("ko-KR");
|
||||
|
||||
return publicRecords
|
||||
.filter((record) => !filters.kind || record.kind === filters.kind)
|
||||
.filter(
|
||||
(record) =>
|
||||
!hasTopicFilter ||
|
||||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
||||
)
|
||||
.filter(
|
||||
(record) =>
|
||||
!hasProjectFilter ||
|
||||
record.projectSlug.toLocaleLowerCase("ko-KR") === requestedProject ||
|
||||
record.projectTitle.toLocaleLowerCase("ko-KR") === requestedProject,
|
||||
)
|
||||
.filter(
|
||||
(record) =>
|
||||
!filters.openQuestionsOnly ||
|
||||
(record.kind === "QUESTION" &&
|
||||
record.questionStatus !== "RESOLVED" &&
|
||||
record.questionStatus !== "ARCHIVED"),
|
||||
)
|
||||
.sort(comparePublishedAt);
|
||||
}
|
||||
|
||||
export function getRecord<K extends RecordKind>(
|
||||
kind: K,
|
||||
slug: string,
|
||||
): Extract<PublicRecord, { kind: K }> | undefined {
|
||||
return publicRecords.find(
|
||||
(record) => record.kind === kind && record.slug === slug,
|
||||
) as Extract<PublicRecord, { kind: K }> | undefined;
|
||||
}
|
||||
|
||||
export function getProject(slug: string): Project | undefined {
|
||||
return projects.find((project) => project.slug === slug);
|
||||
}
|
||||
|
||||
export function getRelease(version: string): Release | undefined {
|
||||
return releases.find((release) => release.version === version);
|
||||
}
|
||||
|
||||
export function getProjectRecords(projectSlug: string): PublicRecord[] {
|
||||
if (!getProject(projectSlug)) return [];
|
||||
return listRecords({ project: projectSlug });
|
||||
}
|
||||
|
||||
export function getProjectDecisions(projectSlug: string): ProjectDecision[] {
|
||||
return [...(getProject(projectSlug)?.decisions ?? [])];
|
||||
}
|
||||
|
||||
export function getProjectActivity(projectSlug: string): ProjectActivity[] {
|
||||
return [...(getProject(projectSlug)?.activity ?? [])];
|
||||
}
|
||||
|
||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||
const project = getProject("backend-skeleton");
|
||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||
const decision = getProjectDecisions("backend-skeleton").find(
|
||||
(item) => item.id === "storage-port-unification",
|
||||
);
|
||||
|
||||
if (!project || !question || !decision) {
|
||||
throw new Error("Missing canonical entity required for the public home focus");
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: "current",
|
||||
label: "현재 작업",
|
||||
title: project.title,
|
||||
summary: project.summary,
|
||||
details: [
|
||||
{ label: "단계", value: project.stage },
|
||||
{ label: "현재 목표", value: project.currentGoal },
|
||||
{ label: "다음 작업", value: project.nextStep },
|
||||
],
|
||||
targetPath: `/projects/${project.slug}`,
|
||||
},
|
||||
{
|
||||
key: "question",
|
||||
label: "열린 질문",
|
||||
title: question.title,
|
||||
summary: question.summary,
|
||||
details: [
|
||||
{ label: "확인한 사실", value: question.facts[0] ?? "" },
|
||||
{ label: "남은 미지수", value: question.unknowns[0] ?? "" },
|
||||
{ label: "다음 검증", value: question.nextValidation },
|
||||
],
|
||||
targetPath: question.path,
|
||||
},
|
||||
{
|
||||
key: "decision",
|
||||
label: "최근 결정",
|
||||
title: decision.statement,
|
||||
summary: decision.rationale,
|
||||
details: [
|
||||
{ label: "영향", value: decision.consequences[0] ?? "" },
|
||||
{ label: "근거", value: decision.evidence[0]?.title ?? "" },
|
||||
],
|
||||
targetPath: `/projects/${project.slug}/decisions#${decision.id}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function recordSearchEntity(record: PublicRecord): SearchablePublicEntity {
|
||||
return {
|
||||
contentType: record.kind,
|
||||
title: record.title,
|
||||
summary: record.summary,
|
||||
path: record.path,
|
||||
topic: record.topic,
|
||||
project: record.projectTitle,
|
||||
publishedAt: record.publishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function projectSearchEntity(project: Project): SearchablePublicEntity {
|
||||
return {
|
||||
contentType: "PROJECT",
|
||||
title: project.title,
|
||||
summary: project.summary,
|
||||
path: `/projects/${project.slug}`,
|
||||
topics: project.topics,
|
||||
project: project.title,
|
||||
};
|
||||
}
|
||||
|
||||
function releaseSearchEntity(release: Release): SearchablePublicEntity {
|
||||
return {
|
||||
contentType: "RELEASE",
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
path: release.path,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
publishedAt: release.publishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function searchPublicContent(query: string): SearchablePublicEntity[] {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
|
||||
const entities = [
|
||||
...publicRecords.map(recordSearchEntity),
|
||||
...projects.map(projectSearchEntity),
|
||||
...releases.map(releaseSearchEntity),
|
||||
];
|
||||
|
||||
if (!normalizedQuery) return entities;
|
||||
|
||||
return entities.filter((entity) =>
|
||||
[
|
||||
entity.title,
|
||||
entity.summary,
|
||||
entity.topic,
|
||||
entity.project,
|
||||
...(entity.topics ?? []),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) =>
|
||||
value.toLocaleLowerCase("ko-KR").includes(normalizedQuery),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const publicContentQueries = Object.freeze({
|
||||
listRecords,
|
||||
getRecord,
|
||||
getProject,
|
||||
getRelease,
|
||||
getProjectRecords,
|
||||
getProjectDecisions,
|
||||
getProjectActivity,
|
||||
getHomeFocusItems,
|
||||
searchPublicContent,
|
||||
}) satisfies PublicContentQueries;
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { components } from "../../contracts/studio/generated.ts";
|
||||
import type {
|
||||
PublicationAggregate,
|
||||
PublicPreview,
|
||||
ValidationReport,
|
||||
WorkingCopy,
|
||||
WorkingCopyDetail,
|
||||
} from "../../contracts/studio/contract.ts";
|
||||
|
||||
export type ValidationResultState = "NOT_RUN" | "INVALID" | "WARNINGS" | "VALID";
|
||||
export type ValidationFreshness = "NONE" | "CURRENT" | "STALE";
|
||||
export type PreviewState = "NONE" | "CURRENT" | "STALE" | "EXPIRED";
|
||||
export type NextAction = components["schemas"]["NextAction"];
|
||||
|
||||
export type ValidationState = {
|
||||
result: ValidationResultState;
|
||||
freshness: ValidationFreshness;
|
||||
};
|
||||
|
||||
export type StudioDocumentState = {
|
||||
validation: ValidationState;
|
||||
preview: PreviewState;
|
||||
nextAction: NextAction;
|
||||
};
|
||||
|
||||
export type DocumentStateInput = {
|
||||
// eslint-disable-next-line no-restricted-globals -- Exact Studio contract field.
|
||||
document: WorkingCopy;
|
||||
validation: ValidationReport | null;
|
||||
preview: PublicPreview | null;
|
||||
publication: PublicationAggregate | null;
|
||||
dependencyRevision: string;
|
||||
now: Date | string;
|
||||
};
|
||||
|
||||
export type WorkingCopyDetailStateInput = WorkingCopyDetail & {
|
||||
now: Date | string;
|
||||
};
|
||||
|
||||
type StateInput = DocumentStateInput | WorkingCopyDetailStateInput;
|
||||
|
||||
function normalize(input: StateInput): DocumentStateInput {
|
||||
if ("validation" in input) return input;
|
||||
|
||||
return {
|
||||
document: input.document,
|
||||
validation: input.currentValidation,
|
||||
preview: input.latestPreview,
|
||||
publication: input.currentPublication,
|
||||
dependencyRevision: input.dependencyRevision,
|
||||
now: input.now,
|
||||
};
|
||||
}
|
||||
|
||||
function timestamp(value: Date | string): number {
|
||||
return value instanceof Date ? value.getTime() : Date.parse(value);
|
||||
}
|
||||
|
||||
function isStrictlyFuture(value: string, now: Date | string): boolean {
|
||||
const expiry = timestamp(value);
|
||||
const current = timestamp(now);
|
||||
return Number.isFinite(expiry) && Number.isFinite(current) && current < expiry;
|
||||
}
|
||||
|
||||
function isExpired(value: string, now: Date | string): boolean {
|
||||
const expiry = timestamp(value);
|
||||
const current = timestamp(now);
|
||||
return Number.isFinite(expiry) && Number.isFinite(current) && current >= expiry;
|
||||
}
|
||||
|
||||
export function deriveValidationState(input: StateInput): ValidationState {
|
||||
const { document, validation, dependencyRevision, now } = normalize(input);
|
||||
if (validation === null) {
|
||||
return { result: "NOT_RUN", freshness: "NONE" };
|
||||
}
|
||||
|
||||
const freshness: ValidationFreshness =
|
||||
validation.validatedVersion === document.version &&
|
||||
isStrictlyFuture(validation.validUntil, now) &&
|
||||
validation.dependencyRevision === dependencyRevision
|
||||
? "CURRENT"
|
||||
: "STALE";
|
||||
|
||||
return { result: validation.status, freshness };
|
||||
}
|
||||
|
||||
export function derivePreviewState(input: StateInput): PreviewState {
|
||||
const normalized = normalize(input);
|
||||
const { document, validation, preview, now } = normalized;
|
||||
if (preview === null) return "NONE";
|
||||
if (isExpired(preview.expiresAt, now)) return "EXPIRED";
|
||||
|
||||
return preview.previewVersion === document.version &&
|
||||
validation !== null &&
|
||||
preview.validationId === validation.validationId &&
|
||||
deriveValidationState(normalized).freshness === "CURRENT"
|
||||
? "CURRENT"
|
||||
: "STALE";
|
||||
}
|
||||
|
||||
function isCompletelyEmpty(document: WorkingCopy): boolean {
|
||||
return [document.title, document.slug, document.summary].every(
|
||||
(value) => value.trim().length === 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveNextAction(input: StateInput): NextAction {
|
||||
const normalized = normalize(input);
|
||||
const { document, publication } = normalized;
|
||||
|
||||
if (publication?.status === "PUBLISHED" && publication.publishedVersion === document.version) {
|
||||
return "NONE";
|
||||
}
|
||||
if (isCompletelyEmpty(document)) return "CONTINUE_EDITING";
|
||||
|
||||
const validation = deriveValidationState(normalized);
|
||||
if (validation.freshness !== "CURRENT") return "VALIDATE";
|
||||
if (validation.result === "INVALID") return "FIX_VALIDATION";
|
||||
|
||||
const preview = derivePreviewState(normalized);
|
||||
if (preview !== "CURRENT") return "CREATE_PREVIEW";
|
||||
return "PUBLISH";
|
||||
}
|
||||
|
||||
export function deriveDocumentState(input: StateInput): StudioDocumentState {
|
||||
const normalized = normalize(input);
|
||||
return {
|
||||
validation: deriveValidationState(normalized),
|
||||
preview: derivePreviewState(normalized),
|
||||
nextAction: deriveNextAction(normalized),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
type RandomUuidSource = { randomUUID?: () => string };
|
||||
|
||||
export function createLocalId(
|
||||
prefix: string,
|
||||
source: RandomUuidSource | null | undefined = globalThis.crypto,
|
||||
now: () => number = Date.now,
|
||||
random: () => number = Math.random,
|
||||
): string {
|
||||
const uuid = source?.randomUUID?.();
|
||||
if (uuid) return `${prefix}-${uuid}`;
|
||||
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
|
||||
return `${prefix}-${now()}-${entropy}`;
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
||||
import type {
|
||||
WorkingCopy,
|
||||
WorkingCopyInput,
|
||||
} from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
import {
|
||||
cursorBinding,
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
} from "../../../src/features/tech-log/adapters/mock/cursor.ts";
|
||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||
import {
|
||||
createMockStudioGateway,
|
||||
createMockStudioState,
|
||||
} from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts";
|
||||
import { stableStringify } from "../../../src/features/tech-log/adapters/mock/stable-stringify.ts";
|
||||
|
||||
const NOW = "2026-08-14T01:00:00.000Z";
|
||||
|
||||
function ids() {
|
||||
let value = 1000;
|
||||
return {
|
||||
next: () =>
|
||||
`00000000-0000-4000-8000-${String(value++).padStart(12, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function gatewayAt(now = NOW) {
|
||||
return createMockStudioGateway({
|
||||
clock: { now: () => new Date(now) },
|
||||
idGenerator: ids(),
|
||||
dependencyRevision: { current: () => "catalog-2026-08-14" },
|
||||
});
|
||||
}
|
||||
|
||||
function inputOf(document: WorkingCopy): WorkingCopyInput {
|
||||
const { id, version, updatedAt, ...input } = document;
|
||||
void id;
|
||||
void version;
|
||||
void updatedAt;
|
||||
return input;
|
||||
}
|
||||
|
||||
function emptyCase(
|
||||
overrides: Partial<Extract<WorkingCopyInput, { kind: "CASE" }>> = {},
|
||||
) {
|
||||
return {
|
||||
kind: "CASE",
|
||||
title: "",
|
||||
slug: "",
|
||||
summary: "",
|
||||
topicId: null,
|
||||
projectId: null,
|
||||
relations: [],
|
||||
problem: "",
|
||||
conclusion: "",
|
||||
environment: "",
|
||||
reproduction: "",
|
||||
lastVerifiedOn: null,
|
||||
bodyMarkdown: "",
|
||||
...overrides,
|
||||
} satisfies Extract<WorkingCopyInput, { kind: "CASE" }>;
|
||||
}
|
||||
|
||||
function isProblem(status: number, code: string, paths?: string[]) {
|
||||
return (error: unknown) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.equal(error.status, status);
|
||||
assert.equal(error.code, code);
|
||||
if (paths) {
|
||||
assert.deepEqual(
|
||||
error.problem.fieldErrors?.map(({ path }) => path),
|
||||
paths,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
test("stable stringify canonicalizes nested object keys and cursors encode the canonical binding deterministically", () => {
|
||||
assert.equal(
|
||||
stableStringify({
|
||||
z: undefined,
|
||||
b: { d: 2, c: 1 },
|
||||
a: [{ b: 2, a: 1 }, undefined],
|
||||
}),
|
||||
'{"a":[{"a":1,"b":2},null],"b":{"c":1,"d":2}}',
|
||||
);
|
||||
|
||||
const binding = cursorBinding({ sort: "TITLE_ASC", q: "" });
|
||||
assert.equal(binding, '{"q":"","sort":"TITLE_ASC"}');
|
||||
const payload = {
|
||||
binding,
|
||||
lastValue: "A",
|
||||
lastId: "11111111-1111-4111-8111-111111111111",
|
||||
};
|
||||
const cursor = encodeCursor(payload);
|
||||
assert.equal(
|
||||
cursor,
|
||||
"eyJiaW5kaW5nIjoie1wicVwiOlwiXCIsXCJzb3J0XCI6XCJUSVRMRV9BU0NcIn0iLCJsYXN0SWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJsYXN0VmFsdWUiOiJBIn0",
|
||||
);
|
||||
assert.deepEqual(decodeCursor(cursor, binding), payload);
|
||||
});
|
||||
|
||||
test("save, replay, conflict, clone isolation, deterministic IDs, and abort follow the gateway contract", async () => {
|
||||
const gateway = gatewayAt();
|
||||
const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||
const after = await gateway.saveDocument(
|
||||
before.document.id,
|
||||
{
|
||||
expectedVersion: before.document.version,
|
||||
document: { ...inputOf(before.document), title: "수정된 제목" },
|
||||
},
|
||||
{ idempotencyKey: "save-1" },
|
||||
);
|
||||
assert.equal(after.document.version, before.document.version + 1);
|
||||
assert.equal(after.currentValidation, null);
|
||||
assert.equal(
|
||||
after.latestPreview?.previewId,
|
||||
before.latestPreview?.previewId,
|
||||
);
|
||||
after.document.title = "consumer mutation";
|
||||
assert.notEqual(
|
||||
(await gateway.getDocument(before.document.id)).document.title,
|
||||
after.document.title,
|
||||
);
|
||||
|
||||
const draft = emptyCase();
|
||||
const first = await gateway.createDocument(draft, {
|
||||
idempotencyKey: "create-1",
|
||||
});
|
||||
assert.equal(first.id, "00000000-0000-4000-8000-000000001000");
|
||||
assert.deepEqual(
|
||||
await gateway.createDocument(draft, { idempotencyKey: "create-1" }),
|
||||
first,
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.createDocument(
|
||||
{ ...draft, title: "different" },
|
||||
{ idempotencyKey: "create-1" },
|
||||
),
|
||||
isProblem(409, "IDEMPOTENCY_KEY_REUSED"),
|
||||
);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(gateway.getDashboard({ signal: controller.signal }), {
|
||||
name: "AbortError",
|
||||
});
|
||||
const post = new AbortController();
|
||||
const pending = gateway.getDashboard({ signal: post.signal });
|
||||
post.abort();
|
||||
await assert.rejects(pending, { name: "AbortError" });
|
||||
|
||||
const a = createMockStudioState();
|
||||
const b = createMockStudioState();
|
||||
a.documents.clear();
|
||||
assert.ok(b.documents.size > 0);
|
||||
});
|
||||
|
||||
test("runtime OpenAPI validation enforces discriminators, extras, dates, UUIDs and Unicode bounds", async () => {
|
||||
const gateway = gatewayAt();
|
||||
const question = {
|
||||
kind: "QUESTION",
|
||||
title: "질문",
|
||||
slug: "runtime-question",
|
||||
summary: "런타임 계약",
|
||||
topicId: null,
|
||||
projectId: null,
|
||||
relations: [],
|
||||
questionStatus: "OPEN",
|
||||
facts: [],
|
||||
assumptions: [],
|
||||
unknowns: [],
|
||||
constraints: [],
|
||||
options: [],
|
||||
nextValidation: "다음 검증",
|
||||
resolution: null,
|
||||
} satisfies Extract<WorkingCopyInput, { kind: "QUESTION" }>;
|
||||
|
||||
for (const [value, pointer] of [
|
||||
[{ ...question, kind: "ARTICLE" }, "/kind"],
|
||||
[{ ...question, extra: true }, "/extra"],
|
||||
[{ ...question, questionStatus: "PAUSED" }, "/questionStatus"],
|
||||
[
|
||||
(({ summary, ...rest }) => {
|
||||
void summary;
|
||||
return rest;
|
||||
})(question),
|
||||
"/summary",
|
||||
],
|
||||
[
|
||||
{ ...question, facts: [{ id: "bad", text: "사실", order: 0 }] },
|
||||
"/facts/0/id",
|
||||
],
|
||||
] as Array<[unknown, string]>) {
|
||||
await assert.rejects(
|
||||
gateway.createDocument(value as WorkingCopyInput, {
|
||||
idempotencyKey: `shape-${pointer}`,
|
||||
}),
|
||||
(error) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.ok(
|
||||
error.problem.fieldErrors?.some(({ path }) => path === pointer),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const reference = await gateway.getDocument(FIXTURE_IDS.stateNonceReference);
|
||||
await assert.rejects(
|
||||
gateway.saveDocument(
|
||||
reference.document.id,
|
||||
{
|
||||
expectedVersion: reference.document.version,
|
||||
document: {
|
||||
...inputOf(reference.document),
|
||||
verifiedOn: "2026-99-99",
|
||||
} as WorkingCopyInput,
|
||||
},
|
||||
{ idempotencyKey: "bad-date" },
|
||||
),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/verifiedOn"]),
|
||||
);
|
||||
|
||||
const emoji = "🧪";
|
||||
const accepted = await gateway.createDocument(
|
||||
emptyCase({ title: emoji.repeat(120) }),
|
||||
{ idempotencyKey: emoji.repeat(200) },
|
||||
);
|
||||
assert.equal([...accepted.title].length, 120);
|
||||
await gateway.listDocuments({ q: emoji.repeat(100) });
|
||||
await assert.rejects(
|
||||
gateway.createDocument(emptyCase({ title: emoji.repeat(121) }), {
|
||||
idempotencyKey: "title-over",
|
||||
}),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/title"]),
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.createDocument(emptyCase(), {
|
||||
idempotencyKey: emoji.repeat(201),
|
||||
}),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/idempotencyKey"]),
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.listDocuments({ q: emoji.repeat(101) }),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/q"]),
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.listDocuments({ projectId: "bad" }),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/projectId"]),
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.getCatalog({} as { type: "TOPIC" }),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/type"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("structural maximums accept the boundary and reject long fields, repeated items and relations", async () => {
|
||||
const gateway = gatewayAt();
|
||||
let current = (await gateway.getDocument(FIXTURE_IDS.fetchJoinCase)).document;
|
||||
for (const field of [
|
||||
"problem",
|
||||
"conclusion",
|
||||
"environment",
|
||||
"reproduction",
|
||||
"bodyMarkdown",
|
||||
] as const) {
|
||||
current = (
|
||||
await gateway.saveDocument(
|
||||
current.id,
|
||||
{
|
||||
expectedVersion: current.version,
|
||||
document: {
|
||||
...inputOf(current),
|
||||
[field]: "x".repeat(100_000),
|
||||
} as WorkingCopyInput,
|
||||
},
|
||||
{ idempotencyKey: `max-${field}` },
|
||||
)
|
||||
).document;
|
||||
await assert.rejects(
|
||||
gateway.saveDocument(
|
||||
current.id,
|
||||
{
|
||||
expectedVersion: current.version,
|
||||
document: {
|
||||
...inputOf(current),
|
||||
[field]: "x".repeat(100_001),
|
||||
} as WorkingCopyInput,
|
||||
},
|
||||
{ idempotencyKey: `over-${field}` },
|
||||
),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", [`/${field}`]),
|
||||
);
|
||||
}
|
||||
|
||||
const reference = await gateway.getDocument(FIXTURE_IDS.stateNonceReference);
|
||||
const base = inputOf(reference.document) as Extract<
|
||||
WorkingCopyInput,
|
||||
{ kind: "REFERENCE" }
|
||||
>;
|
||||
const item = (index: number) => ({
|
||||
id: `10000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||||
text: "내용",
|
||||
order: index,
|
||||
});
|
||||
const fifty = Array.from({ length: 50 }, (_, index) => item(index));
|
||||
await gateway.saveDocument(
|
||||
reference.document.id,
|
||||
{
|
||||
expectedVersion: reference.document.version,
|
||||
document: { ...base, applyWhen: fifty },
|
||||
},
|
||||
{ idempotencyKey: "items-50" },
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.saveDocument(
|
||||
reference.document.id,
|
||||
{
|
||||
expectedVersion: reference.document.version + 1,
|
||||
document: { ...base, applyWhen: [...fifty, item(50)] },
|
||||
},
|
||||
{ idempotencyKey: "items-51" },
|
||||
),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/applyWhen"]),
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.saveDocument(
|
||||
reference.document.id,
|
||||
{
|
||||
expectedVersion: reference.document.version + 1,
|
||||
document: { ...base, applyWhen: [{ ...item(0), text: "" }] },
|
||||
},
|
||||
{ idempotencyKey: "empty-item" },
|
||||
),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/applyWhen/0/text"]),
|
||||
);
|
||||
|
||||
const fetch = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||
const relations = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: `20000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||||
targetId: `30000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
|
||||
reason: "관계",
|
||||
order: index,
|
||||
}));
|
||||
await gateway.saveDocument(
|
||||
fetch.document.id,
|
||||
{
|
||||
expectedVersion: fetch.document.version,
|
||||
document: { ...inputOf(fetch.document), relations },
|
||||
},
|
||||
{ idempotencyKey: "relations-20" },
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.saveDocument(
|
||||
fetch.document.id,
|
||||
{
|
||||
expectedVersion: fetch.document.version + 1,
|
||||
document: {
|
||||
...inputOf(fetch.document),
|
||||
relations: [
|
||||
...relations,
|
||||
{
|
||||
...relations[0],
|
||||
id: "40000000-0000-4000-8000-000000000000",
|
||||
order: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: "relations-21" },
|
||||
),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/relations"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("validation issue order, evidence, preview freshness and warning-set publication transitions are deterministic", async () => {
|
||||
let now = new Date(NOW);
|
||||
let revision = "catalog-2026-08-14";
|
||||
const gateway = createMockStudioGateway({
|
||||
clock: { now: () => new Date(now) },
|
||||
idGenerator: ids(),
|
||||
dependencyRevision: { current: () => revision },
|
||||
});
|
||||
|
||||
const invalid = await gateway.validateDocument(
|
||||
FIXTURE_IDS.edgeTokenQuestion,
|
||||
{ expectedVersion: 2 },
|
||||
{ idempotencyKey: "invalid" },
|
||||
);
|
||||
assert.equal(invalid.status, "INVALID");
|
||||
assert.deepEqual(
|
||||
invalid.issues.map(({ code }) => code),
|
||||
["QUESTION_FACT_REQUIRED", "QUESTION_OPTIONS_FEWER_THAN_TWO"],
|
||||
);
|
||||
|
||||
const unsupported = await gateway.createDocument(
|
||||
emptyCase({
|
||||
title: "근거",
|
||||
slug: "unsupported-evidence",
|
||||
summary: "근거 검증",
|
||||
topicId: FIXTURE_IDS.topicJpa,
|
||||
projectId: FIXTURE_IDS.projectBackend,
|
||||
problem: "문제",
|
||||
conclusion: "결론",
|
||||
lastVerifiedOn: "2026-08-14",
|
||||
bodyMarkdown:
|
||||
':::evidence key="unknown" alt="근거" caption="근거" zoom="true"\n:::',
|
||||
}),
|
||||
{ idempotencyKey: "unsupported-create" },
|
||||
);
|
||||
const unsupportedReport = await gateway.validateDocument(
|
||||
unsupported.id,
|
||||
{ expectedVersion: 1 },
|
||||
{ idempotencyKey: "unsupported-validation" },
|
||||
);
|
||||
assert.ok(
|
||||
unsupportedReport.issues.some(
|
||||
({ code }) => code === "EVIDENCE_UNSUPPORTED",
|
||||
),
|
||||
);
|
||||
|
||||
const document = await gateway.createDocument(
|
||||
emptyCase({
|
||||
title: "경고",
|
||||
slug: "warning-order",
|
||||
summary: "경고 순서",
|
||||
topicId: FIXTURE_IDS.topicJpa,
|
||||
problem: "문제",
|
||||
conclusion: "결론",
|
||||
bodyMarkdown: "본문",
|
||||
lastVerifiedOn: "2026-01-01",
|
||||
}),
|
||||
{ idempotencyKey: "publish-create" },
|
||||
);
|
||||
const report = await gateway.validateDocument(
|
||||
document.id,
|
||||
{ expectedVersion: 1 },
|
||||
{ idempotencyKey: "publish-validation" },
|
||||
);
|
||||
const preview = await gateway.createPreview(
|
||||
document.id,
|
||||
{ expectedVersion: 1, validationId: report.validationId },
|
||||
{ idempotencyKey: "publish-preview" },
|
||||
);
|
||||
const codes = report.issues
|
||||
.filter(({ severity }) => severity === "WARNING")
|
||||
.map(({ code }) => code);
|
||||
assert.deepEqual(codes, [
|
||||
"PROJECT_MISSING",
|
||||
"VERIFICATION_OLDER_THAN_30_DAYS",
|
||||
]);
|
||||
const command = {
|
||||
expectedVersion: 1,
|
||||
validationId: report.validationId,
|
||||
previewId: preview.previewId,
|
||||
};
|
||||
const published = await gateway.publishDocument(
|
||||
document.id,
|
||||
{ ...command, acknowledgedWarningCodes: codes },
|
||||
{ idempotencyKey: "publish" },
|
||||
);
|
||||
assert.equal(published.event.type, "PUBLISHED");
|
||||
assert.deepEqual(
|
||||
await gateway.publishDocument(
|
||||
document.id,
|
||||
{ ...command, acknowledgedWarningCodes: [...codes].reverse() },
|
||||
{ idempotencyKey: "publish" },
|
||||
),
|
||||
published,
|
||||
);
|
||||
|
||||
revision = "catalog-next";
|
||||
assert.equal((await gateway.getCurrentPreview(document.id)).state, "STALE");
|
||||
revision = "catalog-2026-08-14";
|
||||
now = new Date("2026-08-14T01:35:00.000Z");
|
||||
assert.equal((await gateway.getCurrentPreview(document.id)).state, "EXPIRED");
|
||||
});
|
||||
|
||||
test("document and catalog lists support the Studio screen filters and deterministic paging", async () => {
|
||||
const gateway = gatewayAt();
|
||||
const first = await gateway.listDocuments({
|
||||
q: "경계",
|
||||
sort: "TITLE_ASC",
|
||||
limit: 1,
|
||||
});
|
||||
assert.ok(first.nextCursor);
|
||||
const firstItem = first.items[0];
|
||||
assert.ok(firstItem);
|
||||
const second = await gateway.listDocuments({
|
||||
q: " 경계 ",
|
||||
sort: "TITLE_ASC",
|
||||
limit: 1,
|
||||
cursor: first.nextCursor,
|
||||
});
|
||||
assert.notEqual(second.items[0]?.id, firstItem.id);
|
||||
await assert.rejects(
|
||||
gateway.listDocuments({
|
||||
q: "other",
|
||||
sort: "TITLE_ASC",
|
||||
limit: 1,
|
||||
cursor: first.nextCursor,
|
||||
}),
|
||||
isProblem(422, "REQUEST_VALIDATION_FAILED", ["/cursor"]),
|
||||
);
|
||||
assert.ok((await gateway.getCatalog({ type: "TOPIC" })).items.length > 0);
|
||||
});
|
||||
|
||||
test("publication history, immutable snapshots, conflict fixtures and 404 bodies stay precise", async () => {
|
||||
const gateway = gatewayAt();
|
||||
const redis = await gateway.getDocument(FIXTURE_IDS.redisAdapterCase);
|
||||
assert.ok(redis.currentPublication);
|
||||
const noOp = await gateway.publishDocument(
|
||||
redis.document.id,
|
||||
{
|
||||
expectedVersion: redis.document.version,
|
||||
validationId: FIXTURE_IDS.stateValidation,
|
||||
previewId: FIXTURE_IDS.fetchPreview,
|
||||
acknowledgedWarningCodes: [],
|
||||
},
|
||||
{ idempotencyKey: "redis-noop" },
|
||||
);
|
||||
assert.equal(
|
||||
noOp.publication.publicationRevision,
|
||||
redis.currentPublication.publicationRevision,
|
||||
);
|
||||
|
||||
const snapshot = await gateway.getPublicationSnapshot(
|
||||
FIXTURE_IDS.fetchPublishedEvent,
|
||||
);
|
||||
snapshot.renderModel.summary = "consumer mutation";
|
||||
assert.notEqual(
|
||||
(
|
||||
await gateway.getPublicationSnapshot(FIXTURE_IDS.fetchPublishedEvent)
|
||||
).renderModel.summary,
|
||||
snapshot.renderModel.summary,
|
||||
);
|
||||
|
||||
const unpublish = await gateway.unpublishPublication(
|
||||
redis.currentPublication.publicationId,
|
||||
{
|
||||
expectedPublicationRevision:
|
||||
redis.currentPublication.publicationRevision,
|
||||
},
|
||||
{ idempotencyKey: "unpublish" },
|
||||
);
|
||||
assert.equal(
|
||||
unpublish.event.sourcePublishedEventId,
|
||||
FIXTURE_IDS.redisPublishedEvent,
|
||||
);
|
||||
const rows = await gateway.listPublications({ limit: 100 });
|
||||
assert.ok(
|
||||
rows.items.some(
|
||||
({ event, availableActions }) =>
|
||||
event.type === "UNPUBLISHED" &&
|
||||
availableActions.includes("VIEW_SOURCE_SNAPSHOT"),
|
||||
),
|
||||
);
|
||||
|
||||
const conflict = await gateway.getDocument(FIXTURE_IDS.conflictCase);
|
||||
await assert.rejects(
|
||||
gateway.saveDocument(
|
||||
conflict.document.id,
|
||||
{
|
||||
expectedVersion: conflict.document.version,
|
||||
document: inputOf(conflict.document),
|
||||
},
|
||||
{ idempotencyKey: "conflict" },
|
||||
),
|
||||
(error) => {
|
||||
assert.ok(isStudioGatewayError(error));
|
||||
assert.equal(error.code, "VERSION_CONFLICT");
|
||||
assert.deepEqual(error.problem.conflictingFields, [
|
||||
"/title",
|
||||
"/summary",
|
||||
]);
|
||||
assert.equal(
|
||||
error.problem.latestDocument?.document.version,
|
||||
conflict.document.version + 1,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const unknown = "99999999-9999-4999-8999-999999999999";
|
||||
await assert.rejects(
|
||||
gateway.getDocument(unknown),
|
||||
isProblem(404, "DOCUMENT_NOT_FOUND"),
|
||||
);
|
||||
await assert.rejects(
|
||||
gateway.getPublicationSnapshot(unknown),
|
||||
isProblem(404, "PUBLICATION_EVENT_NOT_FOUND"),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import {
|
||||
caseDocument,
|
||||
caseSections,
|
||||
documentHeadings,
|
||||
focusItems,
|
||||
latestEntries,
|
||||
} from "../../../src/features/tech-log/adapters/static/content.ts";
|
||||
import {
|
||||
profile,
|
||||
projects,
|
||||
publicRecords,
|
||||
releases,
|
||||
} from "../../../src/features/tech-log/adapters/static/public-content.ts";
|
||||
import {
|
||||
getHomeFocusItems,
|
||||
getProject,
|
||||
getProjectActivity,
|
||||
getProjectDecisions,
|
||||
getProjectRecords,
|
||||
getRecord,
|
||||
getRelease,
|
||||
listRecords,
|
||||
searchPublicContent,
|
||||
} from "../../../src/features/tech-log/adapters/static/public-query.ts";
|
||||
|
||||
test("filters the canonical record set by kind, topic, project, and open-question state", () => {
|
||||
assert.deepEqual(
|
||||
listRecords({ kind: "CASE" }).map((item) => item.slug),
|
||||
["collection-fetch-join-pagination", "redis-adapter-ttl-boundary"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ topic: " jPa ", project: " BACKEND-SKELETON " }).map(
|
||||
(item) => item.slug,
|
||||
),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ topic: "JPA", project: " Backend Skeleton " }).map(
|
||||
(item) => item.slug,
|
||||
),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(listRecords({ topic: " " }), []);
|
||||
assert.deepEqual(listRecords({ project: " " }), []);
|
||||
assert.deepEqual(
|
||||
listRecords({ topic: "JPA" }).map((item) => item.slug),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ project: "backend-skeleton" }).map((item) => item.slug),
|
||||
[
|
||||
"collection-fetch-join-pagination",
|
||||
"jpa-list-fetch-strategy",
|
||||
"redis-adapter-ttl-boundary",
|
||||
"collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
listRecords({ kind: "QUESTION", openQuestionsOnly: true }).map(
|
||||
(item) => item.slug,
|
||||
),
|
||||
["validate-edge-token-again"],
|
||||
);
|
||||
});
|
||||
|
||||
test("searches public entities by title, summary, topic, and project without duplicate paths", () => {
|
||||
assert.deepEqual(
|
||||
searchPublicContent("JPA").map((item) => item.path),
|
||||
[
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
"/projects/backend-skeleton",
|
||||
],
|
||||
);
|
||||
assert.equal(searchPublicContent("Backend Skeleton").length, 5);
|
||||
assert.deepEqual(
|
||||
searchPublicContent("Keycloak").map((item) => item.path),
|
||||
["/projects/auth-lab"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
searchPublicContent("Storage").map((item) => item.path),
|
||||
["/projects/backend-skeleton"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
searchPublicContent("요청 위조 방지").map((item) => item.path),
|
||||
["/references/state-and-nonce-boundary"],
|
||||
);
|
||||
|
||||
const allPaths = searchPublicContent("").map((item) => item.path);
|
||||
assert.equal(allPaths.length, 9);
|
||||
assert.equal(new Set(allPaths).size, allPaths.length);
|
||||
});
|
||||
|
||||
test("looks up canonical records, projects, and releases while leaving unknown identifiers absent", () => {
|
||||
assert.equal(
|
||||
getRecord("CASE", "collection-fetch-join-pagination")?.title,
|
||||
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
);
|
||||
assert.equal(
|
||||
getRecord("QUESTION", "validate-edge-token-again")?.questionStatus,
|
||||
"OPEN",
|
||||
);
|
||||
assert.equal(getRecord("CASE", "missing"), undefined);
|
||||
assert.equal(getProject("backend-skeleton")?.title, "Backend Skeleton");
|
||||
assert.equal(getProject("missing"), undefined);
|
||||
assert.equal(getRelease("0.1.0")?.version, "0.1.0");
|
||||
assert.equal(getRelease("9.9.9"), undefined);
|
||||
});
|
||||
|
||||
test("derives project records, decisions, and activity from canonical relationships", () => {
|
||||
assert.equal(getProjectRecords("backend-skeleton").length, 4);
|
||||
assert.deepEqual(
|
||||
getProjectDecisions("backend-skeleton").map((item) => item.id),
|
||||
["storage-port-unification", "feed-pagination-boundary"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getProjectActivity("auth-lab").map((item) => item.id),
|
||||
["state-nonce-reference", "edge-trust-boundary"],
|
||||
);
|
||||
assert.deepEqual(getProjectRecords("missing"), []);
|
||||
assert.deepEqual(getProjectDecisions("missing"), []);
|
||||
assert.deepEqual(getProjectActivity("missing"), []);
|
||||
});
|
||||
|
||||
test("keeps the approved canonical corpus and grounded profile", () => {
|
||||
assert.equal(publicRecords.length, 6);
|
||||
assert.equal(projects.length, 2);
|
||||
assert.equal(releases.length, 1);
|
||||
assert.equal(profile.name, "동현");
|
||||
assert.equal(profile.email, undefined);
|
||||
assert.deepEqual(profile.topics, [
|
||||
"Backend Architecture",
|
||||
"JPA",
|
||||
"Authentication",
|
||||
"Redis",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
publicRecords.map((item) => item.path),
|
||||
[
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
"/references/state-and-nonce-boundary",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/questions/validate-edge-token-again",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves the existing home timeline and Fetch Join Case compatibility exactly", () => {
|
||||
assert.deepEqual(
|
||||
latestEntries.map(({ typeLabel, title, path }) => ({
|
||||
typeLabel,
|
||||
title,
|
||||
path,
|
||||
})),
|
||||
[
|
||||
{
|
||||
typeLabel: "CASE",
|
||||
title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
path: "/cases/collection-fetch-join-pagination",
|
||||
},
|
||||
{
|
||||
typeLabel: "PROJECT ACTIVITY",
|
||||
title: "파일 저장소 계약을 하나로 통합했습니다",
|
||||
path: "/projects/backend-skeleton/activity#storage-contract",
|
||||
},
|
||||
{
|
||||
typeLabel: "REFERENCE",
|
||||
title: "Authorization Code Flow에서 state와 nonce의 경계",
|
||||
path: "/references/state-and-nonce-boundary",
|
||||
},
|
||||
{
|
||||
typeLabel: "PROJECT ACTIVITY",
|
||||
title: "oauth2-proxy 뒤에서 토큰을 다시 검증할 것인가",
|
||||
path: "/projects/auth-lab/activity#edge-trust-boundary",
|
||||
},
|
||||
{
|
||||
typeLabel: "CASE",
|
||||
title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
|
||||
path: "/cases/redis-adapter-ttl-boundary",
|
||||
},
|
||||
{
|
||||
typeLabel: "RELEASE",
|
||||
title: "TechLog Public·Studio 경계를 확정했습니다",
|
||||
path: "/releases/0.1.0",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
caseDocument.summary,
|
||||
"반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정을 측정하고, 목록의 페이지 경계를 다시 세웠습니다.",
|
||||
);
|
||||
assert.equal(
|
||||
caseDocument.problem,
|
||||
"FeedItem 20건을 요청했지만 컬렉션 Fetch Join 때문에 DB LIMIT가 사라지고, 전체 부모와 자식 행을 읽은 뒤 메모리에서 20건만 남겼습니다.",
|
||||
);
|
||||
assert.equal(
|
||||
caseDocument.conclusion,
|
||||
"목록 페이징 쿼리와 컬렉션 로딩을 분리하고, 현재 페이지의 부모 키만 IN 배치로 조회합니다.",
|
||||
);
|
||||
assert.equal(
|
||||
caseDocument.dataset,
|
||||
"Dataset: FeedItem 100개, Zipf 편중 Highlight/Mention",
|
||||
);
|
||||
|
||||
const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination");
|
||||
assert.ok(fetchJoin);
|
||||
assert.equal("headings" in fetchJoin, false);
|
||||
assert.deepEqual(
|
||||
documentHeadings,
|
||||
fetchJoin.sections.map(({ id, title }) => ({ id, label: title })),
|
||||
);
|
||||
assert.equal(caseSections, fetchJoin.sections);
|
||||
});
|
||||
|
||||
test("derives public home focus and Case verification labels from canonical content", () => {
|
||||
const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination");
|
||||
const derivedFocusItems = getHomeFocusItems();
|
||||
|
||||
assert.equal(fetchJoin?.lastVerifiedLabel, "2026.08.11");
|
||||
assert.equal(
|
||||
caseDocument.dates,
|
||||
"게시 2026.08.11 · 마지막 검증 2026.08.11",
|
||||
);
|
||||
assert.deepEqual(derivedFocusItems, [
|
||||
{
|
||||
key: "current",
|
||||
label: "현재 작업",
|
||||
title: "Backend Skeleton",
|
||||
summary:
|
||||
"저장소·Redis·JPA·MongoDB 같은 기술을 붙일 때 애플리케이션 경계를 다시 만들지 않도록 공통 계약을 정리하는 프로젝트입니다.",
|
||||
details: [
|
||||
{ label: "단계", value: "DESIGN" },
|
||||
{
|
||||
label: "현재 목표",
|
||||
value: "Filesystem과 Object Storage를 하나의 StoragePort로 통합",
|
||||
},
|
||||
{
|
||||
label: "다음 작업",
|
||||
value: "MinIO Adapter와 공통 계약 테스트 연결",
|
||||
},
|
||||
],
|
||||
targetPath: "/projects/backend-skeleton",
|
||||
},
|
||||
{
|
||||
key: "question",
|
||||
label: "열린 질문",
|
||||
title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?",
|
||||
summary:
|
||||
"Edge에서 인증한 뒤 Resource Server가 무엇을 신뢰할지 결정하기 위한 열린 질문입니다.",
|
||||
details: [
|
||||
{
|
||||
label: "확인한 사실",
|
||||
value:
|
||||
"oauth2-proxy는 Access Token 또는 사용자 식별 헤더를 upstream에 전달할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
label: "남은 미지수",
|
||||
value:
|
||||
"내부 요청에서 사용자 헤더 위조를 어떤 계층이 차단할지 아직 확정하지 않았습니다.",
|
||||
},
|
||||
{
|
||||
label: "다음 검증",
|
||||
value:
|
||||
"토큰 전달안과 신뢰 헤더안을 위협 모델로 비교하고 Edge 우회 요청을 포함한 통합 테스트를 실행합니다.",
|
||||
},
|
||||
],
|
||||
targetPath: "/questions/validate-edge-token-again",
|
||||
},
|
||||
{
|
||||
key: "decision",
|
||||
label: "최근 결정",
|
||||
title:
|
||||
"Filesystem과 Object Storage는 하나의 StoragePort와 서로 다른 Adapter로 구성합니다.",
|
||||
summary:
|
||||
"애플리케이션이 요구하는 저장 의미는 같고 실제 저장 방식만 달라지므로 호출 계약을 기술별로 나누지 않습니다.",
|
||||
details: [
|
||||
{
|
||||
label: "영향",
|
||||
value: "로컬과 MinIO 구현이 같은 계약 테스트를 통과해야 합니다.",
|
||||
},
|
||||
{
|
||||
label: "근거",
|
||||
value: "파일 저장소 계약을 하나로 통합했습니다",
|
||||
},
|
||||
],
|
||||
targetPath:
|
||||
"/projects/backend-skeleton/decisions#storage-port-unification",
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(focusItems, derivedFocusItems);
|
||||
});
|
||||
|
||||
test("keeps template-specific evidence instead of reducing records to search cards", () => {
|
||||
const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination");
|
||||
const stateNonce = getRecord("REFERENCE", "state-and-nonce-boundary");
|
||||
const edgeTrust = getRecord("QUESTION", "validate-edge-token-again");
|
||||
|
||||
assert.equal(fetchJoin?.kind, "CASE");
|
||||
if (fetchJoin?.kind === "CASE") {
|
||||
assert.match(fetchJoin.problem, /DB LIMIT/);
|
||||
assert.match(fetchJoin.conclusion, /IN 배치/);
|
||||
}
|
||||
|
||||
assert.equal(stateNonce?.kind, "REFERENCE");
|
||||
if (stateNonce?.kind === "REFERENCE") {
|
||||
assert.equal(stateNonce.rules.length, 3);
|
||||
assert.match(stateNonce.rules[0].body, /state/);
|
||||
}
|
||||
|
||||
assert.equal(edgeTrust?.kind, "QUESTION");
|
||||
if (edgeTrust?.kind === "QUESTION") {
|
||||
assert.equal(edgeTrust.questionStatus, "OPEN");
|
||||
assert.ok(edgeTrust.facts.length >= 2);
|
||||
assert.match(edgeTrust.nextValidation, /위협 모델/);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
projects[0].currentGoal,
|
||||
"Filesystem과 Object Storage를 하나의 StoragePort로 통합",
|
||||
);
|
||||
assert.match(releases[0].changes[0], /Public/);
|
||||
assert.ok(profile.principles.length >= 3);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts";
|
||||
import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts";
|
||||
import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts";
|
||||
import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts";
|
||||
import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
|
||||
type Equal<Left, Right> =
|
||||
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() =>
|
||||
Value extends Right ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
type Expect<Value extends true> = Value;
|
||||
|
||||
type InstalledInputs = ReturnType<typeof createInstalledFeatureInputs>;
|
||||
type _InstalledIdsAreExact = Expect<
|
||||
Equal<keyof InstalledInputs, "reference-feature" | "tech-log">
|
||||
>;
|
||||
type _TechLogRegistryValueIsExact = Expect<
|
||||
Equal<ApplicationFeatureInputs["tech-log"], TechLogFeatureInput>
|
||||
>;
|
||||
void (0 as unknown as _InstalledIdsAreExact);
|
||||
void (0 as unknown as _TechLogRegistryValueIsExact);
|
||||
|
||||
function inputOf(document: WorkingCopy) {
|
||||
const { id, version, updatedAt, ...input } = document;
|
||||
void id;
|
||||
void version;
|
||||
void updatedAt;
|
||||
return input;
|
||||
}
|
||||
|
||||
function installedInputs(): InstalledInputs {
|
||||
return createInstalledFeatureInputs({
|
||||
contractOperations: {
|
||||
async execute() {
|
||||
throw new Error("reference executor is not used by composition tests");
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("installs TechLog beside the retained reference feature through application-facing inputs", () => {
|
||||
const installed = installedInputs();
|
||||
|
||||
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
|
||||
assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [
|
||||
"createStudioGateway",
|
||||
"publicContent",
|
||||
]);
|
||||
assert.equal(Object.isFrozen(installed), true);
|
||||
assert.equal(Object.isFrozen(installed["tech-log"]), true);
|
||||
assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true);
|
||||
assert.equal(
|
||||
installed["tech-log"].publicContent.getRelease("0.1.0")?.title,
|
||||
"TechLog Public·Studio 경계를 확정했습니다",
|
||||
);
|
||||
});
|
||||
|
||||
test("each createStudioGateway call owns an isolated mutable Studio session", async () => {
|
||||
const installed = installedInputs();
|
||||
const first = installed["tech-log"].createStudioGateway();
|
||||
const second = installed["tech-log"].createStudioGateway();
|
||||
|
||||
assert.notEqual(first, second);
|
||||
const firstBefore = await first.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||
const secondBefore = await second.getDocument(FIXTURE_IDS.fetchJoinCase);
|
||||
assert.equal(firstBefore.document.title, secondBefore.document.title);
|
||||
|
||||
const saved = await first.saveDocument(
|
||||
firstBefore.document.id,
|
||||
{
|
||||
expectedVersion: firstBefore.document.version,
|
||||
document: {
|
||||
...inputOf(firstBefore.document),
|
||||
title: "첫 번째 세션에서만 수정",
|
||||
},
|
||||
},
|
||||
{ idempotencyKey: "session-isolation" },
|
||||
);
|
||||
|
||||
assert.equal(saved.document.title, "첫 번째 세션에서만 수정");
|
||||
assert.equal(
|
||||
(await second.getDocument(FIXTURE_IDS.fetchJoinCase)).document.title,
|
||||
secondBefore.document.title,
|
||||
);
|
||||
assert.equal(
|
||||
installed["tech-log"].publicContent.getRecord(
|
||||
"CASE",
|
||||
"collection-fetch-join-pagination",
|
||||
)?.title,
|
||||
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "vitest";
|
||||
|
||||
import type {
|
||||
PublicationAggregate,
|
||||
PublicPreview,
|
||||
ValidationReport,
|
||||
WorkingCopy,
|
||||
WorkingCopyDetail,
|
||||
} from "../../../src/features/tech-log/contracts/studio/contract.ts";
|
||||
import {
|
||||
deriveDocumentState,
|
||||
deriveNextAction,
|
||||
derivePreviewState,
|
||||
deriveValidationState,
|
||||
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
|
||||
import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts";
|
||||
|
||||
const now = "2026-08-14T12:00:00.000Z";
|
||||
const validUntil = "2026-08-14T12:30:00.000Z";
|
||||
const expiredAt = "2026-08-14T12:00:00.000Z";
|
||||
const futureExpiry = "2026-08-14T12:45:00.000Z";
|
||||
|
||||
function documentAt(
|
||||
version = 7,
|
||||
overrides: Partial<WorkingCopy> = {},
|
||||
): WorkingCopy {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
kind: "CASE",
|
||||
version,
|
||||
title: "Fetch Join 경계",
|
||||
slug: "fetch-join-boundary",
|
||||
summary: "목록 경계를 검증합니다.",
|
||||
topicId: "22222222-2222-4222-8222-222222222222",
|
||||
projectId: null,
|
||||
relations: [],
|
||||
updatedAt: now,
|
||||
problem: "문제",
|
||||
conclusion: "결론",
|
||||
environment: "PostgreSQL",
|
||||
reproduction: "재현",
|
||||
lastVerifiedOn: "2026-08-14",
|
||||
bodyMarkdown: "## 본문",
|
||||
...overrides,
|
||||
} as WorkingCopy;
|
||||
}
|
||||
|
||||
function validationAt(
|
||||
version = 7,
|
||||
overrides: Partial<ValidationReport> = {},
|
||||
): ValidationReport {
|
||||
return {
|
||||
validationId: "33333333-3333-4333-8333-333333333333",
|
||||
documentId: "11111111-1111-4111-8111-111111111111",
|
||||
validatedVersion: version,
|
||||
status: "VALID",
|
||||
issues: [],
|
||||
validatedAt: "2026-08-14T11:50:00.000Z",
|
||||
validUntil,
|
||||
dependencyRevision: "catalog-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function previewAt(
|
||||
version = 7,
|
||||
validationId = validationAt(version).validationId,
|
||||
overrides: Partial<PublicPreview> = {},
|
||||
): PublicPreview {
|
||||
return {
|
||||
previewId: "44444444-4444-4444-8444-444444444444",
|
||||
documentId: "11111111-1111-4111-8111-111111111111",
|
||||
previewVersion: version,
|
||||
validationId,
|
||||
createdAt: "2026-08-14T11:55:00.000Z",
|
||||
expiresAt: futureExpiry,
|
||||
renderModel: {
|
||||
kind: "CASE",
|
||||
title: "Fetch Join 경계",
|
||||
slug: "fetch-join-boundary",
|
||||
summary: "목록 경계를 검증합니다.",
|
||||
topic: {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
label: "JPA",
|
||||
publicPath: null,
|
||||
},
|
||||
project: null,
|
||||
relations: [],
|
||||
blocks: [],
|
||||
},
|
||||
...overrides,
|
||||
} as PublicPreview;
|
||||
}
|
||||
|
||||
function publishedAt(version = 7): PublicationAggregate {
|
||||
return {
|
||||
publicationId: "55555555-5555-4555-8555-555555555555",
|
||||
documentId: "11111111-1111-4111-8111-111111111111",
|
||||
status: "PUBLISHED",
|
||||
publishedVersion: version,
|
||||
publicationRevision: 1,
|
||||
latestEventId: "66666666-6666-4666-8666-666666666666",
|
||||
publicPath: "/cases/fetch-join-boundary",
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
type InputOverrides = Partial<Parameters<typeof deriveNextAction>[0]>;
|
||||
|
||||
function input(overrides: InputOverrides = {}) {
|
||||
return {
|
||||
document: documentAt(),
|
||||
validation: validationAt(),
|
||||
preview: previewAt(),
|
||||
publication: null,
|
||||
now,
|
||||
dependencyRevision: "catalog-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("same published version is NONE before stale validation checks", () => {
|
||||
assert.equal(
|
||||
deriveNextAction({
|
||||
document: documentAt(7),
|
||||
validation: validationAt(7, { validUntil: expiredAt }),
|
||||
preview: previewAt(7, "other-validation", { expiresAt: expiredAt }),
|
||||
publication: publishedAt(7),
|
||||
now,
|
||||
dependencyRevision: "catalog-2",
|
||||
}),
|
||||
"NONE",
|
||||
);
|
||||
});
|
||||
|
||||
test("validation freshness requires the saved version, a future expiry, and matching dependencies", () => {
|
||||
assert.deepEqual(deriveValidationState(input({ validation: null })), {
|
||||
result: "NOT_RUN",
|
||||
freshness: "NONE",
|
||||
});
|
||||
assert.deepEqual(
|
||||
deriveValidationState(input({ validation: validationAt(6) })),
|
||||
{ result: "VALID", freshness: "STALE" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
deriveValidationState(
|
||||
input({ validation: validationAt(7, { validUntil: now }) }),
|
||||
),
|
||||
{ result: "VALID", freshness: "STALE" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
deriveValidationState(input({ dependencyRevision: "catalog-2" })),
|
||||
{ result: "VALID", freshness: "STALE" },
|
||||
);
|
||||
assert.deepEqual(deriveValidationState(input()), {
|
||||
result: "VALID",
|
||||
freshness: "CURRENT",
|
||||
});
|
||||
});
|
||||
|
||||
test("preview expiry takes precedence and all remaining freshness checks are enforced", () => {
|
||||
const validation = validationAt();
|
||||
assert.equal(derivePreviewState(input({ preview: null })), "NONE");
|
||||
assert.equal(
|
||||
derivePreviewState(
|
||||
input({ preview: previewAt(6, validation.validationId) }),
|
||||
),
|
||||
"STALE",
|
||||
);
|
||||
assert.equal(
|
||||
derivePreviewState(input({ preview: previewAt(7, "other-validation") })),
|
||||
"STALE",
|
||||
);
|
||||
assert.equal(
|
||||
derivePreviewState(
|
||||
input({
|
||||
preview: previewAt(6, "other-validation", { expiresAt: now }),
|
||||
}),
|
||||
),
|
||||
"EXPIRED",
|
||||
);
|
||||
assert.equal(derivePreviewState(input()), "CURRENT");
|
||||
assert.equal(
|
||||
derivePreviewState(
|
||||
input({ validation: validationAt(7, { validUntil: now }) }),
|
||||
),
|
||||
"STALE",
|
||||
);
|
||||
assert.equal(
|
||||
derivePreviewState(input({ dependencyRevision: "catalog-2" })),
|
||||
"STALE",
|
||||
);
|
||||
});
|
||||
|
||||
test("save-reset, dependency staleness, invalid reports, warnings and preview gaps are ordered", () => {
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ document: documentAt(7, { title: "", slug: "", summary: "" }) }),
|
||||
),
|
||||
"CONTINUE_EDITING",
|
||||
);
|
||||
assert.equal(deriveNextAction(input({ validation: null })), "VALIDATE");
|
||||
assert.equal(
|
||||
deriveNextAction(input({ dependencyRevision: "catalog-2" })),
|
||||
"VALIDATE",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ validation: validationAt(7, { status: "INVALID" }) }),
|
||||
),
|
||||
"FIX_VALIDATION",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({
|
||||
validation: validationAt(7, { status: "WARNINGS" }),
|
||||
preview: null,
|
||||
}),
|
||||
),
|
||||
"CREATE_PREVIEW",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ validation: validationAt(7, { status: "WARNINGS" }) }),
|
||||
),
|
||||
"PUBLISH",
|
||||
);
|
||||
assert.deepEqual(
|
||||
deriveValidationState(
|
||||
input({
|
||||
validation: validationAt(7, { status: "WARNINGS" }),
|
||||
dependencyRevision: "catalog-2",
|
||||
}),
|
||||
),
|
||||
{ result: "WARNINGS", freshness: "STALE" },
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({
|
||||
validation: validationAt(7, { status: "WARNINGS" }),
|
||||
dependencyRevision: "catalog-2",
|
||||
}),
|
||||
),
|
||||
"VALIDATE",
|
||||
);
|
||||
});
|
||||
|
||||
test("saving retains an old preview as stale and returns to validation", () => {
|
||||
const previousValidation = validationAt(7);
|
||||
const previousPreview = previewAt(7, previousValidation.validationId);
|
||||
const state = deriveDocumentState(
|
||||
input({
|
||||
document: documentAt(8),
|
||||
validation: null,
|
||||
preview: previousPreview,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(state.validation, {
|
||||
result: "NOT_RUN",
|
||||
freshness: "NONE",
|
||||
});
|
||||
assert.equal(state.preview, "STALE");
|
||||
assert.equal(state.nextAction, "VALIDATE");
|
||||
});
|
||||
|
||||
test("unpublished or old-version publications follow the normal next-action flow", () => {
|
||||
assert.equal(
|
||||
deriveNextAction(
|
||||
input({ publication: { ...publishedAt(), status: "UNPUBLISHED" } }),
|
||||
),
|
||||
"PUBLISH",
|
||||
);
|
||||
assert.equal(
|
||||
deriveNextAction(input({ publication: publishedAt(6) })),
|
||||
"PUBLISH",
|
||||
);
|
||||
});
|
||||
|
||||
test("deriveDocumentState is consistent with a WorkingCopyDetail aggregate", () => {
|
||||
const detail: WorkingCopyDetail = {
|
||||
document: documentAt(),
|
||||
currentValidation: validationAt(),
|
||||
latestPreview: previewAt(),
|
||||
currentPublication: publishedAt(),
|
||||
dependencyRevision: "catalog-1",
|
||||
};
|
||||
const explicit = deriveDocumentState({
|
||||
document: detail.document,
|
||||
validation: detail.currentValidation,
|
||||
preview: detail.latestPreview,
|
||||
publication: detail.currentPublication,
|
||||
dependencyRevision: detail.dependencyRevision,
|
||||
now,
|
||||
});
|
||||
const fromDetail = deriveDocumentState({ ...detail, now });
|
||||
|
||||
assert.deepEqual(explicit, {
|
||||
validation: { result: "VALID", freshness: "CURRENT" },
|
||||
preview: "CURRENT",
|
||||
nextAction: "NONE",
|
||||
});
|
||||
assert.deepEqual(fromDetail, explicit);
|
||||
});
|
||||
|
||||
test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs when present", () => {
|
||||
assert.equal(
|
||||
createLocalId("preview", null, () => 1234, () => 0.25),
|
||||
"preview-1234-250000000",
|
||||
);
|
||||
assert.equal(
|
||||
createLocalId("save", { randomUUID: () => "fixture-uuid" }),
|
||||
"save-fixture-uuid",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user