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}`;
}