feat: compose TechLog static and mock adapters

This commit is contained in:
DongHyeonka
2026-08-15 19:36:46 +09:00
parent 708680b28e
commit 27dda3e0e3
18 changed files with 3107 additions and 1 deletions
+8 -1
View File
@@ -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",
);
});