From 27dda3e0e39af32df48f87217e937fa1afd55b46 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Sat, 15 Aug 2026 19:36:46 +0900 Subject: [PATCH] feat: compose TechLog static and mock adapters --- src/features/installed-feature-adapters.ts | 9 +- .../adapters/create-tech-log-feature-input.ts | 18 + src/features/tech-log/adapters/mock/cursor.ts | 39 + .../tech-log/adapters/mock/fixtures.ts | 88 +++ .../tech-log/adapters/mock/mock-state.ts | 28 + .../adapters/mock/mock-studio-gateway.ts | 135 ++++ .../mock/project-public-render-model.ts | 17 + .../adapters/mock/stable-stringify.ts | 12 + .../adapters/mock/validate-working-copy.ts | 169 ++++ .../tech-log/adapters/static/content.ts | 154 ++++ .../adapters/static/public-content.ts | 720 ++++++++++++++++++ .../tech-log/adapters/static/public-query.ts | 221 ++++++ .../tech-log/domain/studio/document-state.ts | 132 ++++ .../tech-log/domain/studio/local-id.ts | 13 + .../tech-log/mock-studio-gateway.test.ts | 599 +++++++++++++++ tests/features/tech-log/public-query.test.ts | 342 +++++++++ .../tech-log/runtime-composition.test.ts | 96 +++ .../tech-log/studio-document-state.test.ts | 316 ++++++++ 18 files changed, 3107 insertions(+), 1 deletion(-) create mode 100644 src/features/tech-log/adapters/create-tech-log-feature-input.ts create mode 100644 src/features/tech-log/adapters/mock/cursor.ts create mode 100644 src/features/tech-log/adapters/mock/fixtures.ts create mode 100644 src/features/tech-log/adapters/mock/mock-state.ts create mode 100644 src/features/tech-log/adapters/mock/mock-studio-gateway.ts create mode 100644 src/features/tech-log/adapters/mock/project-public-render-model.ts create mode 100644 src/features/tech-log/adapters/mock/stable-stringify.ts create mode 100644 src/features/tech-log/adapters/mock/validate-working-copy.ts create mode 100644 src/features/tech-log/adapters/static/content.ts create mode 100644 src/features/tech-log/adapters/static/public-content.ts create mode 100644 src/features/tech-log/adapters/static/public-query.ts create mode 100644 src/features/tech-log/domain/studio/document-state.ts create mode 100644 src/features/tech-log/domain/studio/local-id.ts create mode 100644 tests/features/tech-log/mock-studio-gateway.test.ts create mode 100644 tests/features/tech-log/public-query.test.ts create mode 100644 tests/features/tech-log/runtime-composition.test.ts create mode 100644 tests/features/tech-log/studio-document-state.test.ts diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index ba6c8c9..0ebe6d2 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -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 + Pick< + ApplicationFeatureInputs, + typeof REFERENCE_FEATURE_ID | typeof TECH_LOG_FEATURE_ID + > >; export function createInstalledFeatureInputs( context: Parameters[0], ): InstalledFeatureInputs { const referenceFeature = createReferenceFeatureInstalledInput(context); + const techLogFeature = createTechLogFeatureInstalledInput(); return Object.freeze({ [referenceFeature.featureId]: referenceFeature.input, + [techLogFeature.featureId]: techLogFeature.input, }); } diff --git a/src/features/tech-log/adapters/create-tech-log-feature-input.ts b/src/features/tech-log/adapters/create-tech-log-feature-input.ts new file mode 100644 index 0000000..93aef80 --- /dev/null +++ b/src/features/tech-log/adapters/create-tech-log-feature-input.ts @@ -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, + }); +} diff --git a/src/features/tech-log/adapters/mock/cursor.ts b/src/features/tech-log/adapters/mock/cursor.ts new file mode 100644 index 0000000..55551fe --- /dev/null +++ b/src/features/tech-log/adapters/mock/cursor.ts @@ -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; + 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."); + } +} diff --git a/src/features/tech-log/adapters/mock/fixtures.ts b/src/features/tech-log/adapters/mock/fixtures.ts new file mode 100644 index 0000000..3fe690c --- /dev/null +++ b/src/features/tech-log/adapters/mock/fixtures.ts @@ -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] }; +} diff --git a/src/features/tech-log/adapters/mock/mock-state.ts b/src/features/tech-log/adapters/mock/mock-state.ts new file mode 100644 index 0000000..9d90bb0 --- /dev/null +++ b/src/features/tech-log/adapters/mock/mock-state.ts @@ -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(); readonly validations = new Map(); + readonly previews = new Map(); readonly publications = new Map(); + readonly events = new Map(); readonly snapshots = new Map(); + readonly idempotency = new Map(); readonly issuedCursors = new Set(); + readonly pendingConflicts = new Set(); 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(); } diff --git a/src/features/tech-log/adapters/mock/mock-studio-gateway.ts b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts new file mode 100644 index 0000000..aa2ee60 --- /dev/null +++ b/src/features/tech-log/adapters/mock/mock-studio-gateway.ts @@ -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 = (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 = {}) { + 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 = {}): 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(options: RequestOptions | undefined, work: () => T) { await boundary(options); return clone(work()); } + async function idempotent(operation: string, target: string, request: () => unknown, options: IdempotentOptions, work: () => T): Promise { + 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; }); }, + }; +} diff --git a/src/features/tech-log/adapters/mock/project-public-render-model.ts b/src/features/tech-log/adapters/mock/project-public-render-model.ts new file mode 100644 index 0000000..df10218 --- /dev/null +++ b/src/features/tech-log/adapters/mock/project-public-render-model.ts @@ -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; + +export function projectWorkingCopy( + input: ProjectArguments[0], + catalog: ProjectArguments[1], + context: ProjectArguments[2], +) { + return projectWorkingCopyWithEvidence( + input, + catalog, + context, + isSupportedEvidenceKey, + ); +} diff --git a/src/features/tech-log/adapters/mock/stable-stringify.ts b/src/features/tech-log/adapters/mock/stable-stringify.ts new file mode 100644 index 0000000..3f126d4 --- /dev/null +++ b/src/features/tech-log/adapters/mock/stable-stringify.ts @@ -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)); +} diff --git a/src/features/tech-log/adapters/mock/validate-working-copy.ts b/src/features/tech-log/adapters/mock/validate-working-copy.ts new file mode 100644 index 0000000..09759bd --- /dev/null +++ b/src/features/tech-log/adapters/mock/validate-working-copy.ts @@ -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; documents: ReadonlyArray; +}; + +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 => 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) => { + 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) => { + 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(); const orders = new Set(); + 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)[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(); const targets = new Set(); const orders = new Set(); + 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 }; +} diff --git a/src/features/tech-log/adapters/static/content.ts b/src/features/tech-log/adapters/static/content.ts new file mode 100644 index 0000000..f3fbe1e --- /dev/null +++ b/src/features/tech-log/adapters/static/content.ts @@ -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 = 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 = 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 = [ + ...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 findFeed( + @Param("visibility") Visibility visibility, + Pageable pageable +);`; + +export const splitQueryCode = `Page page = feedItemQuery.findPage( + Visibility.PUBLIC, + PageRequest.of(0, 20, Sort.by( + Sort.Order.desc("firstHighlightedAt"), + Sort.Order.desc("id") + )) +); + +List feedItemIds = page.getContent().stream() + .map(FeedItemRow::id) + .toList(); + +Map> highlights = + highlightQuery.findLatestByFeedItemIds(feedItemIds, 3);`; diff --git a/src/features/tech-log/adapters/static/public-content.ts b/src/features/tech-log/adapters/static/public-content.ts new file mode 100644 index 0000000..00437bf --- /dev/null +++ b/src/features/tech-log/adapters/static/public-content.ts @@ -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; + bullets?: ReadonlyArray; +}; + +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; +}; + +export type CaseRecord = PublicRecordBase & { + kind: "CASE"; + problem: string; + conclusion: string; + environment: string; + verification: string; + lastVerifiedLabel: string; + sections: ReadonlyArray; +}; + +export type ReferenceRule = { + title: string; + body: string; +}; + +export type ReferenceRecord = PublicRecordBase & { + kind: "REFERENCE"; + purpose: string; + rules: ReadonlyArray; + applyWhen: ReadonlyArray; + exceptions: ReadonlyArray; + examples: ReadonlyArray; + verifiedAt: string; +}; + +export type QuestionOption = { + title: string; + description: string; +}; + +export type QuestionRecord = PublicRecordBase & { + kind: "QUESTION"; + questionStatus: QuestionStatus; + facts: ReadonlyArray; + assumptions: ReadonlyArray; + unknowns: ReadonlyArray; + constraints: ReadonlyArray; + options: ReadonlyArray; + 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; + 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; + decisions: ReadonlyArray; + activity: ReadonlyArray; +}; + +export type Release = { + version: string; + path: string; + title: string; + summary: string; + publishedAt: string; + publishedLabel: string; + changes: ReadonlyArray; + reasons: ReadonlyArray; + impacts: ReadonlyArray; + related: ReadonlyArray<{ title: string; path: string }>; +}; + +export type Profile = { + name: string; + introduction: string; + principles: ReadonlyArray<{ title: string; description: string }>; + currentProjectSlugs: ReadonlyArray; + topics: ReadonlyArray; + 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 = [ + { + 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 = [ + { + 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 = [ + { + 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"], +}; diff --git a/src/features/tech-log/adapters/static/public-query.ts b/src/features/tech-log/adapters/static/public-query.ts new file mode 100644 index 0000000..d6866bc --- /dev/null +++ b/src/features/tech-log/adapters/static/public-query.ts @@ -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; + project?: string; + publishedAt?: string; +}; + +function comparePublishedAt( + left: Pick, + right: Pick, +) { + 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( + kind: K, + slug: string, +): Extract | undefined { + return publicRecords.find( + (record) => record.kind === kind && record.slug === slug, + ) as Extract | 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; diff --git a/src/features/tech-log/domain/studio/document-state.ts b/src/features/tech-log/domain/studio/document-state.ts new file mode 100644 index 0000000..4a1d25d --- /dev/null +++ b/src/features/tech-log/domain/studio/document-state.ts @@ -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), + }; +} diff --git a/src/features/tech-log/domain/studio/local-id.ts b/src/features/tech-log/domain/studio/local-id.ts new file mode 100644 index 0000000..1caf6ed --- /dev/null +++ b/src/features/tech-log/domain/studio/local-id.ts @@ -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}`; +} diff --git a/tests/features/tech-log/mock-studio-gateway.test.ts b/tests/features/tech-log/mock-studio-gateway.test.ts new file mode 100644 index 0000000..6cd3928 --- /dev/null +++ b/tests/features/tech-log/mock-studio-gateway.test.ts @@ -0,0 +1,599 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts"; +import type { + WorkingCopy, + WorkingCopyInput, +} from "../../../src/features/tech-log/contracts/studio/contract.ts"; +import { + cursorBinding, + decodeCursor, + encodeCursor, +} from "../../../src/features/tech-log/adapters/mock/cursor.ts"; +import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; +import { + createMockStudioGateway, + createMockStudioState, +} from "../../../src/features/tech-log/adapters/mock/mock-studio-gateway.ts"; +import { stableStringify } from "../../../src/features/tech-log/adapters/mock/stable-stringify.ts"; + +const NOW = "2026-08-14T01:00:00.000Z"; + +function ids() { + let value = 1000; + return { + next: () => + `00000000-0000-4000-8000-${String(value++).padStart(12, "0")}`, + }; +} + +function gatewayAt(now = NOW) { + return createMockStudioGateway({ + clock: { now: () => new Date(now) }, + idGenerator: ids(), + dependencyRevision: { current: () => "catalog-2026-08-14" }, + }); +} + +function inputOf(document: WorkingCopy): WorkingCopyInput { + const { id, version, updatedAt, ...input } = document; + void id; + void version; + void updatedAt; + return input; +} + +function emptyCase( + overrides: Partial> = {}, +) { + return { + kind: "CASE", + title: "", + slug: "", + summary: "", + topicId: null, + projectId: null, + relations: [], + problem: "", + conclusion: "", + environment: "", + reproduction: "", + lastVerifiedOn: null, + bodyMarkdown: "", + ...overrides, + } satisfies Extract; +} + +function isProblem(status: number, code: string, paths?: string[]) { + return (error: unknown) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.status, status); + assert.equal(error.code, code); + if (paths) { + assert.deepEqual( + error.problem.fieldErrors?.map(({ path }) => path), + paths, + ); + } + return true; + }; +} + +test("stable stringify canonicalizes nested object keys and cursors encode the canonical binding deterministically", () => { + assert.equal( + stableStringify({ + z: undefined, + b: { d: 2, c: 1 }, + a: [{ b: 2, a: 1 }, undefined], + }), + '{"a":[{"a":1,"b":2},null],"b":{"c":1,"d":2}}', + ); + + const binding = cursorBinding({ sort: "TITLE_ASC", q: "" }); + assert.equal(binding, '{"q":"","sort":"TITLE_ASC"}'); + const payload = { + binding, + lastValue: "A", + lastId: "11111111-1111-4111-8111-111111111111", + }; + const cursor = encodeCursor(payload); + assert.equal( + cursor, + "eyJiaW5kaW5nIjoie1wicVwiOlwiXCIsXCJzb3J0XCI6XCJUSVRMRV9BU0NcIn0iLCJsYXN0SWQiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJsYXN0VmFsdWUiOiJBIn0", + ); + assert.deepEqual(decodeCursor(cursor, binding), payload); +}); + +test("save, replay, conflict, clone isolation, deterministic IDs, and abort follow the gateway contract", async () => { + const gateway = gatewayAt(); + const before = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const after = await gateway.saveDocument( + before.document.id, + { + expectedVersion: before.document.version, + document: { ...inputOf(before.document), title: "수정된 제목" }, + }, + { idempotencyKey: "save-1" }, + ); + assert.equal(after.document.version, before.document.version + 1); + assert.equal(after.currentValidation, null); + assert.equal( + after.latestPreview?.previewId, + before.latestPreview?.previewId, + ); + after.document.title = "consumer mutation"; + assert.notEqual( + (await gateway.getDocument(before.document.id)).document.title, + after.document.title, + ); + + const draft = emptyCase(); + const first = await gateway.createDocument(draft, { + idempotencyKey: "create-1", + }); + assert.equal(first.id, "00000000-0000-4000-8000-000000001000"); + assert.deepEqual( + await gateway.createDocument(draft, { idempotencyKey: "create-1" }), + first, + ); + await assert.rejects( + gateway.createDocument( + { ...draft, title: "different" }, + { idempotencyKey: "create-1" }, + ), + isProblem(409, "IDEMPOTENCY_KEY_REUSED"), + ); + + const controller = new AbortController(); + controller.abort(); + await assert.rejects(gateway.getDashboard({ signal: controller.signal }), { + name: "AbortError", + }); + const post = new AbortController(); + const pending = gateway.getDashboard({ signal: post.signal }); + post.abort(); + await assert.rejects(pending, { name: "AbortError" }); + + const a = createMockStudioState(); + const b = createMockStudioState(); + a.documents.clear(); + assert.ok(b.documents.size > 0); +}); + +test("runtime OpenAPI validation enforces discriminators, extras, dates, UUIDs and Unicode bounds", async () => { + const gateway = gatewayAt(); + const question = { + kind: "QUESTION", + title: "질문", + slug: "runtime-question", + summary: "런타임 계약", + topicId: null, + projectId: null, + relations: [], + questionStatus: "OPEN", + facts: [], + assumptions: [], + unknowns: [], + constraints: [], + options: [], + nextValidation: "다음 검증", + resolution: null, + } satisfies Extract; + + for (const [value, pointer] of [ + [{ ...question, kind: "ARTICLE" }, "/kind"], + [{ ...question, extra: true }, "/extra"], + [{ ...question, questionStatus: "PAUSED" }, "/questionStatus"], + [ + (({ summary, ...rest }) => { + void summary; + return rest; + })(question), + "/summary", + ], + [ + { ...question, facts: [{ id: "bad", text: "사실", order: 0 }] }, + "/facts/0/id", + ], + ] as Array<[unknown, string]>) { + await assert.rejects( + gateway.createDocument(value as WorkingCopyInput, { + idempotencyKey: `shape-${pointer}`, + }), + (error) => { + assert.ok(isStudioGatewayError(error)); + assert.ok( + error.problem.fieldErrors?.some(({ path }) => path === pointer), + ); + return true; + }, + ); + } + + const reference = await gateway.getDocument(FIXTURE_IDS.stateNonceReference); + await assert.rejects( + gateway.saveDocument( + reference.document.id, + { + expectedVersion: reference.document.version, + document: { + ...inputOf(reference.document), + verifiedOn: "2026-99-99", + } as WorkingCopyInput, + }, + { idempotencyKey: "bad-date" }, + ), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/verifiedOn"]), + ); + + const emoji = "🧪"; + const accepted = await gateway.createDocument( + emptyCase({ title: emoji.repeat(120) }), + { idempotencyKey: emoji.repeat(200) }, + ); + assert.equal([...accepted.title].length, 120); + await gateway.listDocuments({ q: emoji.repeat(100) }); + await assert.rejects( + gateway.createDocument(emptyCase({ title: emoji.repeat(121) }), { + idempotencyKey: "title-over", + }), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/title"]), + ); + await assert.rejects( + gateway.createDocument(emptyCase(), { + idempotencyKey: emoji.repeat(201), + }), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/idempotencyKey"]), + ); + await assert.rejects( + gateway.listDocuments({ q: emoji.repeat(101) }), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/q"]), + ); + await assert.rejects( + gateway.listDocuments({ projectId: "bad" }), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/projectId"]), + ); + await assert.rejects( + gateway.getCatalog({} as { type: "TOPIC" }), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/type"]), + ); +}); + +test("structural maximums accept the boundary and reject long fields, repeated items and relations", async () => { + const gateway = gatewayAt(); + let current = (await gateway.getDocument(FIXTURE_IDS.fetchJoinCase)).document; + for (const field of [ + "problem", + "conclusion", + "environment", + "reproduction", + "bodyMarkdown", + ] as const) { + current = ( + await gateway.saveDocument( + current.id, + { + expectedVersion: current.version, + document: { + ...inputOf(current), + [field]: "x".repeat(100_000), + } as WorkingCopyInput, + }, + { idempotencyKey: `max-${field}` }, + ) + ).document; + await assert.rejects( + gateway.saveDocument( + current.id, + { + expectedVersion: current.version, + document: { + ...inputOf(current), + [field]: "x".repeat(100_001), + } as WorkingCopyInput, + }, + { idempotencyKey: `over-${field}` }, + ), + isProblem(422, "REQUEST_VALIDATION_FAILED", [`/${field}`]), + ); + } + + const reference = await gateway.getDocument(FIXTURE_IDS.stateNonceReference); + const base = inputOf(reference.document) as Extract< + WorkingCopyInput, + { kind: "REFERENCE" } + >; + const item = (index: number) => ({ + id: `10000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + text: "내용", + order: index, + }); + const fifty = Array.from({ length: 50 }, (_, index) => item(index)); + await gateway.saveDocument( + reference.document.id, + { + expectedVersion: reference.document.version, + document: { ...base, applyWhen: fifty }, + }, + { idempotencyKey: "items-50" }, + ); + await assert.rejects( + gateway.saveDocument( + reference.document.id, + { + expectedVersion: reference.document.version + 1, + document: { ...base, applyWhen: [...fifty, item(50)] }, + }, + { idempotencyKey: "items-51" }, + ), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/applyWhen"]), + ); + await assert.rejects( + gateway.saveDocument( + reference.document.id, + { + expectedVersion: reference.document.version + 1, + document: { ...base, applyWhen: [{ ...item(0), text: "" }] }, + }, + { idempotencyKey: "empty-item" }, + ), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/applyWhen/0/text"]), + ); + + const fetch = await gateway.getDocument(FIXTURE_IDS.fetchJoinCase); + const relations = Array.from({ length: 20 }, (_, index) => ({ + id: `20000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + targetId: `30000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + reason: "관계", + order: index, + })); + await gateway.saveDocument( + fetch.document.id, + { + expectedVersion: fetch.document.version, + document: { ...inputOf(fetch.document), relations }, + }, + { idempotencyKey: "relations-20" }, + ); + await assert.rejects( + gateway.saveDocument( + fetch.document.id, + { + expectedVersion: fetch.document.version + 1, + document: { + ...inputOf(fetch.document), + relations: [ + ...relations, + { + ...relations[0], + id: "40000000-0000-4000-8000-000000000000", + order: 20, + }, + ], + }, + }, + { idempotencyKey: "relations-21" }, + ), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/relations"]), + ); +}); + +test("validation issue order, evidence, preview freshness and warning-set publication transitions are deterministic", async () => { + let now = new Date(NOW); + let revision = "catalog-2026-08-14"; + const gateway = createMockStudioGateway({ + clock: { now: () => new Date(now) }, + idGenerator: ids(), + dependencyRevision: { current: () => revision }, + }); + + const invalid = await gateway.validateDocument( + FIXTURE_IDS.edgeTokenQuestion, + { expectedVersion: 2 }, + { idempotencyKey: "invalid" }, + ); + assert.equal(invalid.status, "INVALID"); + assert.deepEqual( + invalid.issues.map(({ code }) => code), + ["QUESTION_FACT_REQUIRED", "QUESTION_OPTIONS_FEWER_THAN_TWO"], + ); + + const unsupported = await gateway.createDocument( + emptyCase({ + title: "근거", + slug: "unsupported-evidence", + summary: "근거 검증", + topicId: FIXTURE_IDS.topicJpa, + projectId: FIXTURE_IDS.projectBackend, + problem: "문제", + conclusion: "결론", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: + ':::evidence key="unknown" alt="근거" caption="근거" zoom="true"\n:::', + }), + { idempotencyKey: "unsupported-create" }, + ); + const unsupportedReport = await gateway.validateDocument( + unsupported.id, + { expectedVersion: 1 }, + { idempotencyKey: "unsupported-validation" }, + ); + assert.ok( + unsupportedReport.issues.some( + ({ code }) => code === "EVIDENCE_UNSUPPORTED", + ), + ); + + const document = await gateway.createDocument( + emptyCase({ + title: "경고", + slug: "warning-order", + summary: "경고 순서", + topicId: FIXTURE_IDS.topicJpa, + problem: "문제", + conclusion: "결론", + bodyMarkdown: "본문", + lastVerifiedOn: "2026-01-01", + }), + { idempotencyKey: "publish-create" }, + ); + const report = await gateway.validateDocument( + document.id, + { expectedVersion: 1 }, + { idempotencyKey: "publish-validation" }, + ); + const preview = await gateway.createPreview( + document.id, + { expectedVersion: 1, validationId: report.validationId }, + { idempotencyKey: "publish-preview" }, + ); + const codes = report.issues + .filter(({ severity }) => severity === "WARNING") + .map(({ code }) => code); + assert.deepEqual(codes, [ + "PROJECT_MISSING", + "VERIFICATION_OLDER_THAN_30_DAYS", + ]); + const command = { + expectedVersion: 1, + validationId: report.validationId, + previewId: preview.previewId, + }; + const published = await gateway.publishDocument( + document.id, + { ...command, acknowledgedWarningCodes: codes }, + { idempotencyKey: "publish" }, + ); + assert.equal(published.event.type, "PUBLISHED"); + assert.deepEqual( + await gateway.publishDocument( + document.id, + { ...command, acknowledgedWarningCodes: [...codes].reverse() }, + { idempotencyKey: "publish" }, + ), + published, + ); + + revision = "catalog-next"; + assert.equal((await gateway.getCurrentPreview(document.id)).state, "STALE"); + revision = "catalog-2026-08-14"; + now = new Date("2026-08-14T01:35:00.000Z"); + assert.equal((await gateway.getCurrentPreview(document.id)).state, "EXPIRED"); +}); + +test("document and catalog lists support the Studio screen filters and deterministic paging", async () => { + const gateway = gatewayAt(); + const first = await gateway.listDocuments({ + q: "경계", + sort: "TITLE_ASC", + limit: 1, + }); + assert.ok(first.nextCursor); + const firstItem = first.items[0]; + assert.ok(firstItem); + const second = await gateway.listDocuments({ + q: " 경계 ", + sort: "TITLE_ASC", + limit: 1, + cursor: first.nextCursor, + }); + assert.notEqual(second.items[0]?.id, firstItem.id); + await assert.rejects( + gateway.listDocuments({ + q: "other", + sort: "TITLE_ASC", + limit: 1, + cursor: first.nextCursor, + }), + isProblem(422, "REQUEST_VALIDATION_FAILED", ["/cursor"]), + ); + assert.ok((await gateway.getCatalog({ type: "TOPIC" })).items.length > 0); +}); + +test("publication history, immutable snapshots, conflict fixtures and 404 bodies stay precise", async () => { + const gateway = gatewayAt(); + const redis = await gateway.getDocument(FIXTURE_IDS.redisAdapterCase); + assert.ok(redis.currentPublication); + const noOp = await gateway.publishDocument( + redis.document.id, + { + expectedVersion: redis.document.version, + validationId: FIXTURE_IDS.stateValidation, + previewId: FIXTURE_IDS.fetchPreview, + acknowledgedWarningCodes: [], + }, + { idempotencyKey: "redis-noop" }, + ); + assert.equal( + noOp.publication.publicationRevision, + redis.currentPublication.publicationRevision, + ); + + const snapshot = await gateway.getPublicationSnapshot( + FIXTURE_IDS.fetchPublishedEvent, + ); + snapshot.renderModel.summary = "consumer mutation"; + assert.notEqual( + ( + await gateway.getPublicationSnapshot(FIXTURE_IDS.fetchPublishedEvent) + ).renderModel.summary, + snapshot.renderModel.summary, + ); + + const unpublish = await gateway.unpublishPublication( + redis.currentPublication.publicationId, + { + expectedPublicationRevision: + redis.currentPublication.publicationRevision, + }, + { idempotencyKey: "unpublish" }, + ); + assert.equal( + unpublish.event.sourcePublishedEventId, + FIXTURE_IDS.redisPublishedEvent, + ); + const rows = await gateway.listPublications({ limit: 100 }); + assert.ok( + rows.items.some( + ({ event, availableActions }) => + event.type === "UNPUBLISHED" && + availableActions.includes("VIEW_SOURCE_SNAPSHOT"), + ), + ); + + const conflict = await gateway.getDocument(FIXTURE_IDS.conflictCase); + await assert.rejects( + gateway.saveDocument( + conflict.document.id, + { + expectedVersion: conflict.document.version, + document: inputOf(conflict.document), + }, + { idempotencyKey: "conflict" }, + ), + (error) => { + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "VERSION_CONFLICT"); + assert.deepEqual(error.problem.conflictingFields, [ + "/title", + "/summary", + ]); + assert.equal( + error.problem.latestDocument?.document.version, + conflict.document.version + 1, + ); + return true; + }, + ); + + const unknown = "99999999-9999-4999-8999-999999999999"; + await assert.rejects( + gateway.getDocument(unknown), + isProblem(404, "DOCUMENT_NOT_FOUND"), + ); + await assert.rejects( + gateway.getPublicationSnapshot(unknown), + isProblem(404, "PUBLICATION_EVENT_NOT_FOUND"), + ); +}); diff --git a/tests/features/tech-log/public-query.test.ts b/tests/features/tech-log/public-query.test.ts new file mode 100644 index 0000000..12e7953 --- /dev/null +++ b/tests/features/tech-log/public-query.test.ts @@ -0,0 +1,342 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + caseDocument, + caseSections, + documentHeadings, + focusItems, + latestEntries, +} from "../../../src/features/tech-log/adapters/static/content.ts"; +import { + profile, + projects, + publicRecords, + releases, +} from "../../../src/features/tech-log/adapters/static/public-content.ts"; +import { + getHomeFocusItems, + getProject, + getProjectActivity, + getProjectDecisions, + getProjectRecords, + getRecord, + getRelease, + listRecords, + searchPublicContent, +} from "../../../src/features/tech-log/adapters/static/public-query.ts"; + +test("filters the canonical record set by kind, topic, project, and open-question state", () => { + assert.deepEqual( + listRecords({ kind: "CASE" }).map((item) => item.slug), + ["collection-fetch-join-pagination", "redis-adapter-ttl-boundary"], + ); + assert.deepEqual( + listRecords({ topic: " jPa ", project: " BACKEND-SKELETON " }).map( + (item) => item.slug, + ), + [ + "collection-fetch-join-pagination", + "jpa-list-fetch-strategy", + "collection-fetch-join-with-pagination", + ], + ); + assert.deepEqual( + listRecords({ topic: "JPA", project: " Backend Skeleton " }).map( + (item) => item.slug, + ), + [ + "collection-fetch-join-pagination", + "jpa-list-fetch-strategy", + "collection-fetch-join-with-pagination", + ], + ); + assert.deepEqual(listRecords({ topic: " " }), []); + assert.deepEqual(listRecords({ project: " " }), []); + assert.deepEqual( + listRecords({ topic: "JPA" }).map((item) => item.slug), + [ + "collection-fetch-join-pagination", + "jpa-list-fetch-strategy", + "collection-fetch-join-with-pagination", + ], + ); + assert.deepEqual( + listRecords({ project: "backend-skeleton" }).map((item) => item.slug), + [ + "collection-fetch-join-pagination", + "jpa-list-fetch-strategy", + "redis-adapter-ttl-boundary", + "collection-fetch-join-with-pagination", + ], + ); + assert.deepEqual( + listRecords({ kind: "QUESTION", openQuestionsOnly: true }).map( + (item) => item.slug, + ), + ["validate-edge-token-again"], + ); +}); + +test("searches public entities by title, summary, topic, and project without duplicate paths", () => { + assert.deepEqual( + searchPublicContent("JPA").map((item) => item.path), + [ + "/cases/collection-fetch-join-pagination", + "/references/jpa-list-fetch-strategy", + "/questions/collection-fetch-join-with-pagination", + "/projects/backend-skeleton", + ], + ); + assert.equal(searchPublicContent("Backend Skeleton").length, 5); + assert.deepEqual( + searchPublicContent("Keycloak").map((item) => item.path), + ["/projects/auth-lab"], + ); + assert.deepEqual( + searchPublicContent("Storage").map((item) => item.path), + ["/projects/backend-skeleton"], + ); + assert.deepEqual( + searchPublicContent("요청 위조 방지").map((item) => item.path), + ["/references/state-and-nonce-boundary"], + ); + + const allPaths = searchPublicContent("").map((item) => item.path); + assert.equal(allPaths.length, 9); + assert.equal(new Set(allPaths).size, allPaths.length); +}); + +test("looks up canonical records, projects, and releases while leaving unknown identifiers absent", () => { + assert.equal( + getRecord("CASE", "collection-fetch-join-pagination")?.title, + "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", + ); + assert.equal( + getRecord("QUESTION", "validate-edge-token-again")?.questionStatus, + "OPEN", + ); + assert.equal(getRecord("CASE", "missing"), undefined); + assert.equal(getProject("backend-skeleton")?.title, "Backend Skeleton"); + assert.equal(getProject("missing"), undefined); + assert.equal(getRelease("0.1.0")?.version, "0.1.0"); + assert.equal(getRelease("9.9.9"), undefined); +}); + +test("derives project records, decisions, and activity from canonical relationships", () => { + assert.equal(getProjectRecords("backend-skeleton").length, 4); + assert.deepEqual( + getProjectDecisions("backend-skeleton").map((item) => item.id), + ["storage-port-unification", "feed-pagination-boundary"], + ); + assert.deepEqual( + getProjectActivity("auth-lab").map((item) => item.id), + ["state-nonce-reference", "edge-trust-boundary"], + ); + assert.deepEqual(getProjectRecords("missing"), []); + assert.deepEqual(getProjectDecisions("missing"), []); + assert.deepEqual(getProjectActivity("missing"), []); +}); + +test("keeps the approved canonical corpus and grounded profile", () => { + assert.equal(publicRecords.length, 6); + assert.equal(projects.length, 2); + assert.equal(releases.length, 1); + assert.equal(profile.name, "동현"); + assert.equal(profile.email, undefined); + assert.deepEqual(profile.topics, [ + "Backend Architecture", + "JPA", + "Authentication", + "Redis", + ]); + assert.deepEqual( + publicRecords.map((item) => item.path), + [ + "/cases/collection-fetch-join-pagination", + "/cases/redis-adapter-ttl-boundary", + "/references/state-and-nonce-boundary", + "/references/jpa-list-fetch-strategy", + "/questions/validate-edge-token-again", + "/questions/collection-fetch-join-with-pagination", + ], + ); +}); + +test("preserves the existing home timeline and Fetch Join Case compatibility exactly", () => { + assert.deepEqual( + latestEntries.map(({ typeLabel, title, path }) => ({ + typeLabel, + title, + path, + })), + [ + { + typeLabel: "CASE", + title: "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", + path: "/cases/collection-fetch-join-pagination", + }, + { + typeLabel: "PROJECT ACTIVITY", + title: "파일 저장소 계약을 하나로 통합했습니다", + path: "/projects/backend-skeleton/activity#storage-contract", + }, + { + typeLabel: "REFERENCE", + title: "Authorization Code Flow에서 state와 nonce의 경계", + path: "/references/state-and-nonce-boundary", + }, + { + typeLabel: "PROJECT ACTIVITY", + title: "oauth2-proxy 뒤에서 토큰을 다시 검증할 것인가", + path: "/projects/auth-lab/activity#edge-trust-boundary", + }, + { + typeLabel: "CASE", + title: "Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유", + path: "/cases/redis-adapter-ttl-boundary", + }, + { + typeLabel: "RELEASE", + title: "TechLog Public·Studio 경계를 확정했습니다", + path: "/releases/0.1.0", + }, + ], + ); + + assert.equal( + caseDocument.summary, + "반환된 20건 뒤에서 전체 컬렉션이 로드되는 과정을 측정하고, 목록의 페이지 경계를 다시 세웠습니다.", + ); + assert.equal( + caseDocument.problem, + "FeedItem 20건을 요청했지만 컬렉션 Fetch Join 때문에 DB LIMIT가 사라지고, 전체 부모와 자식 행을 읽은 뒤 메모리에서 20건만 남겼습니다.", + ); + assert.equal( + caseDocument.conclusion, + "목록 페이징 쿼리와 컬렉션 로딩을 분리하고, 현재 페이지의 부모 키만 IN 배치로 조회합니다.", + ); + assert.equal( + caseDocument.dataset, + "Dataset: FeedItem 100개, Zipf 편중 Highlight/Mention", + ); + + const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination"); + assert.ok(fetchJoin); + assert.equal("headings" in fetchJoin, false); + assert.deepEqual( + documentHeadings, + fetchJoin.sections.map(({ id, title }) => ({ id, label: title })), + ); + assert.equal(caseSections, fetchJoin.sections); +}); + +test("derives public home focus and Case verification labels from canonical content", () => { + const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination"); + const derivedFocusItems = getHomeFocusItems(); + + assert.equal(fetchJoin?.lastVerifiedLabel, "2026.08.11"); + assert.equal( + caseDocument.dates, + "게시 2026.08.11 · 마지막 검증 2026.08.11", + ); + assert.deepEqual(derivedFocusItems, [ + { + key: "current", + label: "현재 작업", + title: "Backend Skeleton", + summary: + "저장소·Redis·JPA·MongoDB 같은 기술을 붙일 때 애플리케이션 경계를 다시 만들지 않도록 공통 계약을 정리하는 프로젝트입니다.", + details: [ + { label: "단계", value: "DESIGN" }, + { + label: "현재 목표", + value: "Filesystem과 Object Storage를 하나의 StoragePort로 통합", + }, + { + label: "다음 작업", + value: "MinIO Adapter와 공통 계약 테스트 연결", + }, + ], + targetPath: "/projects/backend-skeleton", + }, + { + key: "question", + label: "열린 질문", + title: "oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가?", + summary: + "Edge에서 인증한 뒤 Resource Server가 무엇을 신뢰할지 결정하기 위한 열린 질문입니다.", + details: [ + { + label: "확인한 사실", + value: + "oauth2-proxy는 Access Token 또는 사용자 식별 헤더를 upstream에 전달할 수 있습니다.", + }, + { + label: "남은 미지수", + value: + "내부 요청에서 사용자 헤더 위조를 어떤 계층이 차단할지 아직 확정하지 않았습니다.", + }, + { + label: "다음 검증", + value: + "토큰 전달안과 신뢰 헤더안을 위협 모델로 비교하고 Edge 우회 요청을 포함한 통합 테스트를 실행합니다.", + }, + ], + targetPath: "/questions/validate-edge-token-again", + }, + { + key: "decision", + label: "최근 결정", + title: + "Filesystem과 Object Storage는 하나의 StoragePort와 서로 다른 Adapter로 구성합니다.", + summary: + "애플리케이션이 요구하는 저장 의미는 같고 실제 저장 방식만 달라지므로 호출 계약을 기술별로 나누지 않습니다.", + details: [ + { + label: "영향", + value: "로컬과 MinIO 구현이 같은 계약 테스트를 통과해야 합니다.", + }, + { + label: "근거", + value: "파일 저장소 계약을 하나로 통합했습니다", + }, + ], + targetPath: + "/projects/backend-skeleton/decisions#storage-port-unification", + }, + ]); + assert.deepEqual(focusItems, derivedFocusItems); +}); + +test("keeps template-specific evidence instead of reducing records to search cards", () => { + const fetchJoin = getRecord("CASE", "collection-fetch-join-pagination"); + const stateNonce = getRecord("REFERENCE", "state-and-nonce-boundary"); + const edgeTrust = getRecord("QUESTION", "validate-edge-token-again"); + + assert.equal(fetchJoin?.kind, "CASE"); + if (fetchJoin?.kind === "CASE") { + assert.match(fetchJoin.problem, /DB LIMIT/); + assert.match(fetchJoin.conclusion, /IN 배치/); + } + + assert.equal(stateNonce?.kind, "REFERENCE"); + if (stateNonce?.kind === "REFERENCE") { + assert.equal(stateNonce.rules.length, 3); + assert.match(stateNonce.rules[0].body, /state/); + } + + assert.equal(edgeTrust?.kind, "QUESTION"); + if (edgeTrust?.kind === "QUESTION") { + assert.equal(edgeTrust.questionStatus, "OPEN"); + assert.ok(edgeTrust.facts.length >= 2); + assert.match(edgeTrust.nextValidation, /위협 모델/); + } + + assert.equal( + projects[0].currentGoal, + "Filesystem과 Object Storage를 하나의 StoragePort로 통합", + ); + assert.match(releases[0].changes[0], /Public/); + assert.ok(profile.principles.length >= 3); +}); diff --git a/tests/features/tech-log/runtime-composition.test.ts b/tests/features/tech-log/runtime-composition.test.ts new file mode 100644 index 0000000..9069c61 --- /dev/null +++ b/tests/features/tech-log/runtime-composition.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import type { ApplicationFeatureInputs } from "../../../src/application/ports/in/application-api.ts"; +import { createInstalledFeatureInputs } from "../../../src/features/installed-feature-adapters.ts"; +import { FIXTURE_IDS } from "../../../src/features/tech-log/adapters/mock/fixtures.ts"; +import type { TechLogFeatureInput } from "../../../src/features/tech-log/application/tech-log-feature-input.ts"; +import type { WorkingCopy } from "../../../src/features/tech-log/contracts/studio/contract.ts"; + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => + Value extends Right ? 1 : 2 + ? true + : false; +type Expect = Value; + +type InstalledInputs = ReturnType; +type _InstalledIdsAreExact = Expect< + Equal +>; +type _TechLogRegistryValueIsExact = Expect< + Equal +>; +void (0 as unknown as _InstalledIdsAreExact); +void (0 as unknown as _TechLogRegistryValueIsExact); + +function inputOf(document: WorkingCopy) { + const { id, version, updatedAt, ...input } = document; + void id; + void version; + void updatedAt; + return input; +} + +function installedInputs(): InstalledInputs { + return createInstalledFeatureInputs({ + contractOperations: { + async execute() { + throw new Error("reference executor is not used by composition tests"); + }, + }, + }); +} + +test("installs TechLog beside the retained reference feature through application-facing inputs", () => { + const installed = installedInputs(); + + assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]); + assert.deepEqual(Object.keys(installed["tech-log"]).sort(), [ + "createStudioGateway", + "publicContent", + ]); + assert.equal(Object.isFrozen(installed), true); + assert.equal(Object.isFrozen(installed["tech-log"]), true); + assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true); + assert.equal( + installed["tech-log"].publicContent.getRelease("0.1.0")?.title, + "TechLog Public·Studio 경계를 확정했습니다", + ); +}); + +test("each createStudioGateway call owns an isolated mutable Studio session", async () => { + const installed = installedInputs(); + const first = installed["tech-log"].createStudioGateway(); + const second = installed["tech-log"].createStudioGateway(); + + assert.notEqual(first, second); + const firstBefore = await first.getDocument(FIXTURE_IDS.fetchJoinCase); + const secondBefore = await second.getDocument(FIXTURE_IDS.fetchJoinCase); + assert.equal(firstBefore.document.title, secondBefore.document.title); + + const saved = await first.saveDocument( + firstBefore.document.id, + { + expectedVersion: firstBefore.document.version, + document: { + ...inputOf(firstBefore.document), + title: "첫 번째 세션에서만 수정", + }, + }, + { idempotencyKey: "session-isolation" }, + ); + + assert.equal(saved.document.title, "첫 번째 세션에서만 수정"); + assert.equal( + (await second.getDocument(FIXTURE_IDS.fetchJoinCase)).document.title, + secondBefore.document.title, + ); + assert.equal( + installed["tech-log"].publicContent.getRecord( + "CASE", + "collection-fetch-join-pagination", + )?.title, + "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", + ); +}); diff --git a/tests/features/tech-log/studio-document-state.test.ts b/tests/features/tech-log/studio-document-state.test.ts new file mode 100644 index 0000000..57ff824 --- /dev/null +++ b/tests/features/tech-log/studio-document-state.test.ts @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import type { + PublicationAggregate, + PublicPreview, + ValidationReport, + WorkingCopy, + WorkingCopyDetail, +} from "../../../src/features/tech-log/contracts/studio/contract.ts"; +import { + deriveDocumentState, + deriveNextAction, + derivePreviewState, + deriveValidationState, +} from "../../../src/features/tech-log/domain/studio/document-state.ts"; +import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts"; + +const now = "2026-08-14T12:00:00.000Z"; +const validUntil = "2026-08-14T12:30:00.000Z"; +const expiredAt = "2026-08-14T12:00:00.000Z"; +const futureExpiry = "2026-08-14T12:45:00.000Z"; + +function documentAt( + version = 7, + overrides: Partial = {}, +): WorkingCopy { + return { + id: "11111111-1111-4111-8111-111111111111", + kind: "CASE", + version, + title: "Fetch Join 경계", + slug: "fetch-join-boundary", + summary: "목록 경계를 검증합니다.", + topicId: "22222222-2222-4222-8222-222222222222", + projectId: null, + relations: [], + updatedAt: now, + problem: "문제", + conclusion: "결론", + environment: "PostgreSQL", + reproduction: "재현", + lastVerifiedOn: "2026-08-14", + bodyMarkdown: "## 본문", + ...overrides, + } as WorkingCopy; +} + +function validationAt( + version = 7, + overrides: Partial = {}, +): ValidationReport { + return { + validationId: "33333333-3333-4333-8333-333333333333", + documentId: "11111111-1111-4111-8111-111111111111", + validatedVersion: version, + status: "VALID", + issues: [], + validatedAt: "2026-08-14T11:50:00.000Z", + validUntil, + dependencyRevision: "catalog-1", + ...overrides, + }; +} + +function previewAt( + version = 7, + validationId = validationAt(version).validationId, + overrides: Partial = {}, +): PublicPreview { + return { + previewId: "44444444-4444-4444-8444-444444444444", + documentId: "11111111-1111-4111-8111-111111111111", + previewVersion: version, + validationId, + createdAt: "2026-08-14T11:55:00.000Z", + expiresAt: futureExpiry, + renderModel: { + kind: "CASE", + title: "Fetch Join 경계", + slug: "fetch-join-boundary", + summary: "목록 경계를 검증합니다.", + topic: { + id: "22222222-2222-4222-8222-222222222222", + label: "JPA", + publicPath: null, + }, + project: null, + relations: [], + blocks: [], + }, + ...overrides, + } as PublicPreview; +} + +function publishedAt(version = 7): PublicationAggregate { + return { + publicationId: "55555555-5555-4555-8555-555555555555", + documentId: "11111111-1111-4111-8111-111111111111", + status: "PUBLISHED", + publishedVersion: version, + publicationRevision: 1, + latestEventId: "66666666-6666-4666-8666-666666666666", + publicPath: "/cases/fetch-join-boundary", + updatedAt: now, + }; +} + +type InputOverrides = Partial[0]>; + +function input(overrides: InputOverrides = {}) { + return { + document: documentAt(), + validation: validationAt(), + preview: previewAt(), + publication: null, + now, + dependencyRevision: "catalog-1", + ...overrides, + }; +} + +test("same published version is NONE before stale validation checks", () => { + assert.equal( + deriveNextAction({ + document: documentAt(7), + validation: validationAt(7, { validUntil: expiredAt }), + preview: previewAt(7, "other-validation", { expiresAt: expiredAt }), + publication: publishedAt(7), + now, + dependencyRevision: "catalog-2", + }), + "NONE", + ); +}); + +test("validation freshness requires the saved version, a future expiry, and matching dependencies", () => { + assert.deepEqual(deriveValidationState(input({ validation: null })), { + result: "NOT_RUN", + freshness: "NONE", + }); + assert.deepEqual( + deriveValidationState(input({ validation: validationAt(6) })), + { result: "VALID", freshness: "STALE" }, + ); + assert.deepEqual( + deriveValidationState( + input({ validation: validationAt(7, { validUntil: now }) }), + ), + { result: "VALID", freshness: "STALE" }, + ); + assert.deepEqual( + deriveValidationState(input({ dependencyRevision: "catalog-2" })), + { result: "VALID", freshness: "STALE" }, + ); + assert.deepEqual(deriveValidationState(input()), { + result: "VALID", + freshness: "CURRENT", + }); +}); + +test("preview expiry takes precedence and all remaining freshness checks are enforced", () => { + const validation = validationAt(); + assert.equal(derivePreviewState(input({ preview: null })), "NONE"); + assert.equal( + derivePreviewState( + input({ preview: previewAt(6, validation.validationId) }), + ), + "STALE", + ); + assert.equal( + derivePreviewState(input({ preview: previewAt(7, "other-validation") })), + "STALE", + ); + assert.equal( + derivePreviewState( + input({ + preview: previewAt(6, "other-validation", { expiresAt: now }), + }), + ), + "EXPIRED", + ); + assert.equal(derivePreviewState(input()), "CURRENT"); + assert.equal( + derivePreviewState( + input({ validation: validationAt(7, { validUntil: now }) }), + ), + "STALE", + ); + assert.equal( + derivePreviewState(input({ dependencyRevision: "catalog-2" })), + "STALE", + ); +}); + +test("save-reset, dependency staleness, invalid reports, warnings and preview gaps are ordered", () => { + assert.equal( + deriveNextAction( + input({ document: documentAt(7, { title: "", slug: "", summary: "" }) }), + ), + "CONTINUE_EDITING", + ); + assert.equal(deriveNextAction(input({ validation: null })), "VALIDATE"); + assert.equal( + deriveNextAction(input({ dependencyRevision: "catalog-2" })), + "VALIDATE", + ); + assert.equal( + deriveNextAction( + input({ validation: validationAt(7, { status: "INVALID" }) }), + ), + "FIX_VALIDATION", + ); + assert.equal( + deriveNextAction( + input({ + validation: validationAt(7, { status: "WARNINGS" }), + preview: null, + }), + ), + "CREATE_PREVIEW", + ); + assert.equal( + deriveNextAction( + input({ validation: validationAt(7, { status: "WARNINGS" }) }), + ), + "PUBLISH", + ); + assert.deepEqual( + deriveValidationState( + input({ + validation: validationAt(7, { status: "WARNINGS" }), + dependencyRevision: "catalog-2", + }), + ), + { result: "WARNINGS", freshness: "STALE" }, + ); + assert.equal( + deriveNextAction( + input({ + validation: validationAt(7, { status: "WARNINGS" }), + dependencyRevision: "catalog-2", + }), + ), + "VALIDATE", + ); +}); + +test("saving retains an old preview as stale and returns to validation", () => { + const previousValidation = validationAt(7); + const previousPreview = previewAt(7, previousValidation.validationId); + const state = deriveDocumentState( + input({ + document: documentAt(8), + validation: null, + preview: previousPreview, + }), + ); + + assert.deepEqual(state.validation, { + result: "NOT_RUN", + freshness: "NONE", + }); + assert.equal(state.preview, "STALE"); + assert.equal(state.nextAction, "VALIDATE"); +}); + +test("unpublished or old-version publications follow the normal next-action flow", () => { + assert.equal( + deriveNextAction( + input({ publication: { ...publishedAt(), status: "UNPUBLISHED" } }), + ), + "PUBLISH", + ); + assert.equal( + deriveNextAction(input({ publication: publishedAt(6) })), + "PUBLISH", + ); +}); + +test("deriveDocumentState is consistent with a WorkingCopyDetail aggregate", () => { + const detail: WorkingCopyDetail = { + document: documentAt(), + currentValidation: validationAt(), + latestPreview: previewAt(), + currentPublication: publishedAt(), + dependencyRevision: "catalog-1", + }; + const explicit = deriveDocumentState({ + document: detail.document, + validation: detail.currentValidation, + preview: detail.latestPreview, + publication: detail.currentPublication, + dependencyRevision: detail.dependencyRevision, + now, + }); + const fromDetail = deriveDocumentState({ ...detail, now }); + + assert.deepEqual(explicit, { + validation: { result: "VALID", freshness: "CURRENT" }, + preview: "CURRENT", + nextAction: "NONE", + }); + assert.deepEqual(fromDetail, explicit); +}); + +test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs when present", () => { + assert.equal( + createLocalId("preview", null, () => 1234, () => 0.25), + "preview-1234-250000000", + ); + assert.equal( + createLocalId("save", { randomUUID: () => "fixture-uuid" }), + "save-fixture-uuid", + ); +});