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