diff --git a/public/release-manifest.json b/public/release-manifest.json index 8f2e61e..0bfc560 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,8 +38,15 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:5a67c3ce96f6be3a9314040f3039ca32a81f9eaf4402c88a745e235d183f23df", + "setDigest": "sha256:8ae48a17a30f07a0672fbc2c46ea9ebe66637bd16f6914079b3bf31b8009d12f", "packages": [ + { + "packageId": "@tech-log/public-contract", + "version": "2.0.0", + "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", + "runtimeProtocolVersion": 1, + "sourceRevision": "55a9599" + }, { "packageId": "@tech-log/studio-contract", "version": "3.0.0", diff --git a/src/features/installed-contract-contributions.ts b/src/features/installed-contract-contributions.ts index c77949e..448d646 100644 --- a/src/features/installed-contract-contributions.ts +++ b/src/features/installed-contract-contributions.ts @@ -6,6 +6,7 @@ import { import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "./reference-feature/contracts/reference-feature-contract-contribution.ts"; import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts"; import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; +import { TECH_LOG_PUBLIC_CONTRIBUTION } from "./tech-log/contracts/tech-log-public-contract-contribution.ts"; import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-studio-contract-contribution.ts"; /** @@ -18,8 +19,12 @@ import { TECH_LOG_STUDIO_CONTRIBUTION } from "./tech-log/contracts/tech-log-stud export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = Object.freeze( INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID) - ? [REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, TECH_LOG_STUDIO_CONTRIBUTION] - : [TECH_LOG_STUDIO_CONTRIBUTION], + ? [ + REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION, + TECH_LOG_STUDIO_CONTRIBUTION, + TECH_LOG_PUBLIC_CONTRIBUTION, + ] + : [TECH_LOG_STUDIO_CONTRIBUTION, TECH_LOG_PUBLIC_CONTRIBUTION], ); export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( 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 index 1be8f03..416506f 100644 --- a/src/features/tech-log/adapters/create-tech-log-feature-input.ts +++ b/src/features/tech-log/adapters/create-tech-log-feature-input.ts @@ -12,6 +12,7 @@ import { import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts"; import { createMockStudioAssetGateway } from "./mock/mock-studio-asset-gateway.ts"; import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts"; +import { createHttpPublicContentGateway } from "./http/http-public-content-gateway.ts"; import { publicContentQueries } from "./static/public-query.ts"; /** @@ -67,8 +68,16 @@ export function createTechLogFeatureInstalledInput( ? createMockStudioGateway({ assets: mockAssets }) : createHttpStudioGateway({ operations: context.contractOperations }); + // The public read source switches independently of Studio: the two are + // different services, and the combination that matters today is an authoring + // backend that is live while the public read API is not. + const publicContent = + context.publicSource === "MOCK" + ? publicContentQueries + : createHttpPublicContentGateway({ operations: context.contractOperations }); + const input: TechLogFeatureInput = Object.freeze({ - publicContent: publicContentQueries, + publicContent, createStudioGateway, createStudioAssetGateway, }); diff --git a/src/features/tech-log/adapters/http/http-public-content-gateway.ts b/src/features/tech-log/adapters/http/http-public-content-gateway.ts new file mode 100644 index 0000000..cb8acf3 --- /dev/null +++ b/src/features/tech-log/adapters/http/http-public-content-gateway.ts @@ -0,0 +1,353 @@ +import type { + HomeFocusItem, + ProjectActivity, + ProjectDecision, + Project, + PublicContentQueries, + PublicRecord, + QuestionRecord, + RecordFilters, + RecordKind, + Release, + SearchablePublicEntity, +} from "../../application/ports/public-content-queries.ts"; +import { + activityItemToActivity, + baseOf, + dateLabel, + decisionItemToDecision, + flattenRelations, + knowledgeListItemToRecord, + markdownLines, + markdownSections, + questionListItemToRecord, + releaseDetailToRelease, + searchItemToEntity, +} from "./public-content-mapping.ts"; +import type { StudioOperationExecutor } from "./http-studio-gateway.ts"; + +const ROUTE_ID = "TECH_LOG_PUBLIC"; + +/** + * A missing slug is an answer, not a failure. + * + * The port returns `undefined` for a record that is not published, and the + * screens turn that into their not-found route. So a 404 is unwrapped here + * rather than thrown — throwing would put the terminal-error surface on a page + * whose real state is "this does not exist". + */ +const NOT_FOUND = Symbol("not-found"); + +export type PublicContentGatewayError = Error & { readonly failure?: unknown }; + +function gatewayError(operationId: string, detail: string): PublicContentGatewayError { + const error = new Error(`${operationId}: ${detail}`) as PublicContentGatewayError; + error.name = "PublicContentGatewayError"; + return error; +} + +export function createHttpPublicContentGateway( + deps: Readonly<{ operations: StudioOperationExecutor }>, +): PublicContentQueries { + async function read(operationId: string, input: unknown): Promise { + const outcome = await deps.operations.execute(operationId, input, { routeId: ROUTE_ID }); + if (outcome.kind === "SUCCESS") return outcome.value as T; + if (outcome.kind === "PROBLEM") { + const problem = outcome.problem as Readonly<{ status?: number; code?: string }> | null; + if (problem?.status === 404 || problem?.code === "NOT_FOUND") return NOT_FOUND; + throw gatewayError(operationId, problem?.code ?? "PROBLEM"); + } + throw gatewayError(operationId, outcome.kind); + } + + async function readOrThrow(operationId: string, input: unknown): Promise { + const value = await read(operationId, input); + if (value === NOT_FOUND) throw gatewayError(operationId, "NOT_FOUND"); + return value; + } + + type Page = Readonly<{ items?: readonly Readonly>[] }>; + + /** + * `listRecords` is one port method over two endpoints: the contract splits + * knowledge (Case, Reference) from questions because they page and filter + * differently. A caller that asks for one kind must not pay for the other, so + * the unfiltered call is the only one that fans out. + */ + async function listRecords(filters: RecordFilters = {}): Promise { + const wantsQuestions = !filters.kind || filters.kind === "QUESTION"; + const wantsKnowledge = !filters.kind || filters.kind !== "QUESTION"; + const query = { + ...(filters.topic ? { topic: filters.topic } : {}), + ...(filters.project ? { project: filters.project } : {}), + }; + const [knowledge, questions] = await Promise.all([ + wantsKnowledge + ? readOrThrow("exploreKnowledge", { + ...query, + ...(filters.kind && filters.kind !== "QUESTION" ? { type: filters.kind } : {}), + }) + : Promise.resolve({ items: [] } as Page), + wantsQuestions + ? readOrThrow("exploreQuestions", { + ...query, + ...(filters.openQuestionsOnly ? { status: "OPEN" } : {}), + }) + : Promise.resolve({ items: [] } as Page), + ]); + const records = [ + ...(knowledge.items ?? []).map(knowledgeListItemToRecord).filter((r): r is PublicRecord => r !== null), + ...(questions.items ?? []).map(questionListItemToRecord), + ]; + return records.sort((left, right) => right.publishedAt.localeCompare(left.publishedAt)); + } + + /** + * The cast at each return is not laziness. `kind` is a generic parameter, so + * narrowing it inside the body does not narrow `Extract` + * with it — the compiler cannot know the branch it took corresponds to the K it + * was given. The discriminant on each object is a literal, so the shape is + * checked; only the tie back to K is asserted. + */ + async function getRecord( + kind: K, + slug: string, + ): Promise | undefined> { + const operationId = + kind === "CASE" ? "getPublicCase" : kind === "REFERENCE" ? "getPublicReference" : "getPublicQuestion"; + const detail = await read>>(operationId, { slug }); + if (detail === NOT_FOUND) return undefined; + + const canonicalPath = String(detail.canonicalPath ?? ""); + const groups = (detail.relations as Readonly>) ?? {}; + + if (kind === "CASE") { + const body = (detail.case as Readonly>) ?? {}; + return Object.freeze({ + ...baseOf("CASE", slug, { + title: body.title as string, + summary: body.problemSummary as string, + path: canonicalPath, + primaryTopic: body.primaryTopic as never, + primaryProject: body.primaryProject as never, + publishedAt: body.publishedAt as string, + relations: flattenRelations(groups, { + originQuestion: "이 기록이 시작된 질문", + projectDecisions: "이 기록이 뒷받침하는 결정", + derivedReferences: "이 기록에서 정리된 기준", + relatedCases: "관련 기록", + }), + }), + kind: "CASE", + problem: (body.problemSummary as string) ?? "", + conclusion: (body.conclusionSummary as string) ?? "", + environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "), + // The Case document renders a verification line. The contract has no + // field for it — verification lives in the body — so it stays empty + // rather than being guessed from a heading. + verification: "", + lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string), + sections: markdownSections(body.content as string), + }) as unknown as Extract; + } + + if (kind === "REFERENCE") { + const body = (detail.reference as Readonly>) ?? {}; + return Object.freeze({ + ...baseOf("REFERENCE", slug, { + title: body.title as string, + summary: body.purposeSummary as string, + path: canonicalPath, + primaryTopic: body.primaryTopic as never, + primaryProject: body.primaryProject as never, + publishedAt: body.publishedAt as string, + relations: flattenRelations(groups, { + originCases: "이 기준이 나온 기록", + projectDecisions: "이 기준을 따르는 결정", + relatedReferences: "관련 기준", + }), + }), + kind: "REFERENCE", + purpose: (body.purposeSummary as string) ?? "", + rules: Object.freeze( + markdownSections(body.content as string).map((section) => ({ + title: section.title, + body: section.paragraphs.join("\n"), + })), + ), + applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)), + exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)), + examples: Object.freeze(markdownLines(body.examplesMarkdown as string)), + verifiedAt: dateLabel(body.lastVerifiedAt as string), + }) as unknown as Extract; + } + + const body = (detail.question as Readonly>) ?? {}; + const points = (body.points as readonly Readonly>[] | undefined) ?? []; + const pointsOf = (group: string) => + Object.freeze( + points + .filter((point) => point.group === group) + .flatMap((point) => (point.items as readonly string[] | undefined) ?? []), + ); + return Object.freeze({ + ...baseOf("QUESTION", slug, { + title: body.question as string, + summary: body.summary as string, + path: canonicalPath, + primaryTopic: body.primaryTopic as never, + primaryProject: body.primaryProject as never, + publishedAt: body.updatedAt as string, + relations: flattenRelations(groups, { + derivedCases: "이 질문에서 나온 기록", + projectDecisions: "이 질문이 이끈 결정", + relatedQuestions: "관련 질문", + }), + }), + kind: "QUESTION", + questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN", + facts: pointsOf("KNOWN_FACT"), + assumptions: pointsOf("ASSUMPTION"), + unknowns: pointsOf("UNRESOLVED"), + constraints: pointsOf("CONSTRAINT"), + options: Object.freeze([]), + nextValidation: (body.nextVerification as string) ?? "", + }) as unknown as Extract; + } + + async function getProject(slug: string): Promise { + const detail = await read>>("getPublicProject", { slug }); + if (detail === NOT_FOUND) return undefined; + const body = (detail.project as Readonly>) ?? {}; + const [decisions, activity] = await Promise.all([ + getProjectDecisions(slug), + getProjectActivity(slug), + ]); + return Object.freeze({ + slug, + title: String(body.name ?? ""), + summary: String(body.oneLinePurpose ?? ""), + thesis: String(body.purpose ?? body.oneLinePurpose ?? ""), + stage: body.phase === "VALIDATION" ? "VALIDATION" : "DESIGN", + currentGoal: String(body.currentObjective ?? ""), + nextStep: String(body.nextStep ?? ""), + topics: Object.freeze( + ((body.topics as readonly Readonly<{ name?: string }>[] | undefined) ?? []) + .map((topic) => topic.name ?? "") + .filter((name) => name.length > 0), + ), + decisions: Object.freeze(decisions), + activity: Object.freeze(activity), + }); + } + + async function getProjectDecisions(projectSlug: string): Promise { + const page = await read("listPublicProjectDecisions", { slug: projectSlug }); + if (page === NOT_FOUND) return []; + return (page.items ?? []).map(decisionItemToDecision); + } + + async function getProjectActivity(projectSlug: string): Promise { + const page = await read("listPublicProjectActivities", { slug: projectSlug }); + if (page === NOT_FOUND) return []; + return (page.items ?? []).map(activityItemToActivity); + } + + async function getProjectRecords(projectSlug: string): Promise { + const page = await read("listPublicProjectRecords", { slug: projectSlug }); + if (page === NOT_FOUND) return []; + return (page.items ?? []) + .map(knowledgeListItemToRecord) + .filter((record): record is PublicRecord => record !== null); + } + + async function getRelease(version: string): Promise { + const detail = await read>>("getPublicRelease", { version }); + if (detail === NOT_FOUND) return undefined; + return releaseDetailToRelease(detail, version); + } + + /** + * The home screen shows up to three focus cards. The contract returns them as + * one object with a named slot per kind rather than a list, because each slot + * has its own shape; the order below is the order the screen renders them in. + */ + async function getHomeFocusItems(): Promise { + const home = await read> }>>( + "getPublicHome", + {}, + ); + if (home === NOT_FOUND) return []; + const focus = (home.focus ?? {}) as Readonly>>>; + const items: HomeFocusItem[] = []; + const work = focus.currentWork; + if (work) { + items.push( + Object.freeze({ + key: "current", + label: "지금 하는 일", + title: String(work.projectName ?? ""), + summary: String(work.purpose ?? ""), + details: Object.freeze([ + { label: "단계", value: String(work.phase ?? "") }, + { label: "현재 목표", value: String(work.currentObjective ?? "") }, + { label: "다음 작업", value: String(work.nextStep ?? "") }, + ]), + targetPath: String(work.projectPath ?? "/projects"), + }), + ); + } + const question = focus.openQuestion; + if (question) { + items.push( + Object.freeze({ + key: "question", + label: "열린 질문", + title: String(question.question ?? ""), + summary: String(question.summary ?? ""), + details: Object.freeze([ + { label: "확인한 사실", value: ((question.knownFacts as readonly string[]) ?? []).join(" · ") }, + { label: "미해결", value: ((question.unresolvedPoints as readonly string[]) ?? []).join(" · ") }, + { label: "다음 검증", value: String(question.nextVerification ?? "") }, + ]), + targetPath: String(question.questionPath ?? "/explore/questions"), + }), + ); + } + const decision = focus.recentDecision; + if (decision) { + items.push( + Object.freeze({ + key: "decision", + label: "최근 결정", + title: String(decision.statement ?? ""), + summary: String(decision.rationale ?? ""), + details: Object.freeze([ + { label: "결정일", value: dateLabel(decision.decidedAt as string) }, + { label: "영향", value: ((decision.consequences as readonly string[]) ?? []).join(" · ") }, + ]), + targetPath: String(decision.decisionPath ?? "/projects"), + }), + ); + } + return items; + } + + async function searchPublicContent(query: string): Promise { + const page = await read("searchPublicResources", query ? { q: query } : {}); + if (page === NOT_FOUND) return []; + return (page.items ?? []).map(searchItemToEntity); + } + + return Object.freeze({ + listRecords, + getRecord, + getProject, + getRelease, + getProjectRecords, + getProjectDecisions, + getProjectActivity, + getHomeFocusItems, + searchPublicContent, + }); +} diff --git a/src/features/tech-log/adapters/http/public-content-mapping.ts b/src/features/tech-log/adapters/http/public-content-mapping.ts new file mode 100644 index 0000000..a81384b --- /dev/null +++ b/src/features/tech-log/adapters/http/public-content-mapping.ts @@ -0,0 +1,313 @@ +import type { + CaseRecord, + ProjectActivity, + ProjectDecision, + PublicRecord, + QuestionRecord, + RecordSection, + ReferenceRecord, + Release, + SearchablePublicEntity, +} from "../../application/ports/public-content-queries.ts"; + +/** + * The contract and the screens disagree about shape, on purpose. + * + * The contract speaks in what the server stores — timestamps, one markdown body, + * relations grouped by their kind. The screens were built against a catalog that + * spoke in what a page renders — formatted labels, sections, one flat relation + * list. Neither is wrong, and translating here rather than at either end is what + * keeps the presentation components untouched by this migration. + * + * Where the contract has no counterpart the value is empty rather than invented, + * and the gap is named at the call site. + */ + +const DATE_LABEL = new Intl.DateTimeFormat("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + timeZone: "UTC", +}); + +/** `2026. 08. 20.` → `2026.08.20`, the form the fixture used. */ +export function dateLabel(value: string | null | undefined): string { + if (!value) return ""; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return ""; + return DATE_LABEL.format(parsed).replaceAll(" ", "").replace(/\.$/u, ""); +} + +export function isoDate(value: string | null | undefined): string { + if (!value) return ""; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString(); +} + +/** + * The contract carries one markdown body; the document components render an + * ordered list of titled sections. Splitting on `##` reproduces that structure + * without a full renderer: everything before the first heading is the lead, and + * each heading opens a section whose bullets are its `-`/`*` lines. + * + * This is deliberately not the Studio parser (`parseCaseContent`). That one + * produces the canonical render-block union the editor needs — inline marks, + * evidence directives, tables — which is a richer tree than `RecordSection` can + * hold. Reusing it would mean flattening its output back down to this shape, and + * flattening loses exactly the blocks that made it worth using. + */ +export function markdownSections(body: string | null | undefined): RecordSection[] { + if (!body) return []; + const sections: RecordSection[] = []; + let current: { id: string; title: string; paragraphs: string[]; bullets: string[] } | null = null; + const flush = () => { + if (!current) return; + sections.push( + Object.freeze({ + id: current.id, + title: current.title, + paragraphs: Object.freeze([...current.paragraphs]), + ...(current.bullets.length > 0 ? { bullets: Object.freeze([...current.bullets]) } : {}), + }), + ); + }; + for (const rawLine of body.split(/\r?\n/u)) { + const line = rawLine.trim(); + const heading = /^#{2,3}\s+(.*)$/u.exec(line); + if (heading) { + flush(); + const title = heading[1]!.trim(); + current = { id: slugOf(title, sections.length), title, paragraphs: [], bullets: [] }; + continue; + } + if (!current) { + if (line.length === 0) continue; + current = { id: "lead", title: "", paragraphs: [], bullets: [] }; + } + if (line.length === 0) continue; + const bullet = /^[-*]\s+(.*)$/u.exec(line); + if (bullet) current.bullets.push(bullet[1]!.trim()); + else current.paragraphs.push(line); + } + flush(); + return sections; +} + +/** Markdown that is really a list — the release document's four bodies are. */ +export function markdownLines(body: string | null | undefined): string[] { + if (!body) return []; + return body + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.replace(/^[-*]\s+/u, "")); +} + +function slugOf(title: string, index: number): string { + const normalized = title + .toLocaleLowerCase("ko-KR") + .replace(/[^\p{Letter}\p{Number}]+/gu, "-") + .replace(/^-+|-+$/gu, ""); + return normalized.length > 0 ? normalized : `section-${index + 1}`; +} + +type Related = Readonly<{ type?: string; title?: string; summary?: string; path?: string }>; + +/** + * The contract groups relations by why they relate (origin question, derived + * references, related cases); the renderer takes one list where the reason is a + * label. Flattening keeps the group name as that label. + */ +export function flattenRelations( + groups: Readonly>, + labels: Readonly>, +): ReadonlyArray<{ reason: string; title: string; path: string }> { + const flat: { reason: string; title: string; path: string }[] = []; + for (const [group, value] of Object.entries(groups)) { + if (!value) continue; + const reason = labels[group] ?? group; + for (const entry of Array.isArray(value) ? value : [value as Related]) { + if (!entry?.path || !entry.title) continue; + flat.push({ reason, title: entry.title, path: entry.path }); + } + } + return Object.freeze(flat); +} + +type Summary = Readonly<{ name?: string; slug?: string; path?: string }> | undefined; + +export function baseOf( + kind: PublicRecord["kind"], + slug: string, + fields: Readonly<{ + title?: string; + summary?: string; + path?: string; + primaryTopic?: Summary; + primaryProject?: Summary; + publishedAt?: string | null; + relations?: ReadonlyArray<{ reason: string; title: string; path: string }>; + }>, +) { + return { + kind, + slug, + title: fields.title ?? "", + summary: fields.summary ?? "", + path: fields.path ?? "", + topic: fields.primaryTopic?.name ?? "", + topicSlug: fields.primaryTopic?.slug ?? "", + projectSlug: fields.primaryProject?.slug ?? "", + projectTitle: fields.primaryProject?.name ?? "", + publishedAt: isoDate(fields.publishedAt), + publishedLabel: dateLabel(fields.publishedAt), + visibility: "PUBLIC" as const, + relations: fields.relations ?? Object.freeze([]), + }; +} + +/** + * A list endpoint answers with what a list row needs, not with a whole document. + * The port's type is the full record, so the detail fields are filled empty here + * and the detail screens fetch by slug. That is the same trip the fixture made + * for free; it is a real extra request now, and the alternative — widening the + * list response — would send every body to render a title. + */ +export function knowledgeListItemToRecord(item: Readonly>): PublicRecord | null { + const type = String(item.type ?? ""); + const path = String(item.path ?? ""); + const slug = path.split("/").filter(Boolean).pop() ?? ""; + const base = baseOf(type === "REFERENCE" ? "REFERENCE" : "CASE", slug, { + title: item.title as string, + summary: (item.primarySummary as string) ?? "", + path, + primaryTopic: item.primaryTopic as Summary, + primaryProject: item.primaryProject as Summary, + publishedAt: item.publishedAt as string, + }); + if (type === "CASE") { + return Object.freeze({ + ...base, + kind: "CASE", + problem: (item.primarySummary as string) ?? "", + conclusion: (item.secondarySummary as string) ?? "", + environment: "", + verification: "", + lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string), + sections: Object.freeze([]), + }) as CaseRecord; + } + if (type === "REFERENCE") { + return Object.freeze({ + ...base, + kind: "REFERENCE", + purpose: (item.primarySummary as string) ?? "", + rules: Object.freeze([]), + applyWhen: Object.freeze([]), + exceptions: Object.freeze([]), + examples: Object.freeze([]), + verifiedAt: dateLabel(item.lastVerifiedAt as string), + }) as ReferenceRecord; + } + return null; +} + +export function questionListItemToRecord(item: Readonly>): QuestionRecord { + const path = String(item.path ?? ""); + const slug = path.split("/").filter(Boolean).pop() ?? ""; + return Object.freeze({ + ...baseOf("QUESTION", slug, { + title: item.question as string, + summary: (item.summary as string) ?? "", + path, + primaryProject: item.primaryProject as Summary, + publishedAt: item.updatedAt as string, + }), + kind: "QUESTION", + questionStatus: (item.status as QuestionRecord["questionStatus"]) ?? "OPEN", + facts: Object.freeze([]), + assumptions: Object.freeze([]), + unknowns: Object.freeze([]), + constraints: Object.freeze([]), + options: Object.freeze([]), + nextValidation: (item.nextVerification as string) ?? "", + }) as QuestionRecord; +} + +export function decisionItemToDecision(item: Readonly>): ProjectDecision { + const sources = [item.sourceQuestion, item.sourceCase] + .filter((entry): entry is Related => Boolean(entry)) + .map((entry) => ({ title: entry.title ?? "", path: entry.path ?? "" })); + return Object.freeze({ + id: String(item.id ?? ""), + status: (item.status as ProjectDecision["status"]) ?? "PROPOSED", + date: dateLabel(item.decidedAt as string), + title: String(item.statement ?? ""), + statement: String(item.statement ?? ""), + rationale: String(item.rationaleSummary ?? ""), + // The list response carries a rationale summary, not the consequence list the + // decision screen renders; the contract has no field for it here. + consequences: Object.freeze([]), + evidence: Object.freeze(sources), + }); +} + +export function activityItemToActivity( + item: Readonly>, + index: number, +): ProjectActivity { + const occurredAt = item.occurredAt as string; + const relatedPath = (item.relatedPath as string) ?? ""; + return Object.freeze({ + id: `activity-${index + 1}`, + date: dateLabel(occurredAt), + dateTime: isoDate(occurredAt), + type: (item.type as ProjectActivity["type"]) ?? "PROJECT UPDATE", + title: String(item.title ?? ""), + summary: String(item.summary ?? ""), + path: relatedPath, + ...(relatedPath ? { recordPath: relatedPath } : {}), + }); +} + +export function releaseDetailToRelease( + detail: Readonly>, + version: string, +): Release { + const related = (detail.relatedRecords as readonly Related[] | undefined) ?? []; + return Object.freeze({ + version: String(detail.version ?? version), + path: `/releases/${String(detail.version ?? version)}`, + title: String(detail.title ?? ""), + summary: String(detail.summary ?? ""), + publishedAt: isoDate(detail.releasedOn as string), + publishedLabel: dateLabel(detail.releasedOn as string), + changes: Object.freeze(markdownLines(detail.changesMarkdown as string)), + reasons: Object.freeze(markdownLines(detail.reasonMarkdown as string)), + impacts: Object.freeze([ + ...markdownLines(detail.userImpactMarkdown as string), + ...markdownLines(detail.implementationImpactMarkdown as string), + ]), + related: Object.freeze( + related + .filter((entry) => entry.path && entry.title) + .map((entry) => ({ title: entry.title!, path: entry.path! })), + ), + }); +} + +export function searchItemToEntity( + item: Readonly>, +): SearchablePublicEntity { + const topic = (item.primaryTopic as Summary)?.name; + const project = (item.primaryProject as Summary)?.name; + return Object.freeze({ + contentType: (item.contentType as SearchablePublicEntity["contentType"]) ?? "CASE", + title: String(item.title ?? ""), + summary: String(item.snippet ?? ""), + path: String(item.path ?? ""), + ...(topic ? { topic } : {}), + ...(project ? { project } : {}), + ...(item.publishedAt ? { publishedAt: isoDate(item.publishedAt as string) } : {}), + }); +} diff --git a/src/features/tech-log/contracts/tech-log-public-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-public-contract-contribution.ts new file mode 100644 index 0000000..c3f8779 --- /dev/null +++ b/src/features/tech-log/contracts/tech-log-public-contract-contribution.ts @@ -0,0 +1,160 @@ +import type { + InstalledContractContribution, + InstalledHttpContract, +} from "../../../contracts/external-contract-runtime.ts"; +import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts"; +import canonicalSource from "./public/canonical-source.json" with { type: "json" }; +import { envelopeData, envelopeError, passthroughInput } from "./tech-log-studio-contract-contribution.ts"; + +type PathValues = Readonly>; +type QueryEntries = readonly (readonly [string, string])[]; + +const NO_PATH: PathValues = Object.freeze({}); +const NO_QUERY = Object.freeze([]) as QueryEntries; +const PROBLEM = envelopeError(); + +function queryOf(input: Readonly>): QueryEntries { + const entries: (readonly [string, string])[] = []; + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null || value === "") continue; + entries.push([key, String(value)]); + } + return Object.freeze(entries); +} + +/** + * Every public operation has the same shape, which is the point of holding this + * surface apart from Studio: all 18 are GET, none carries a body, none needs a + * session, and none needs a CSRF token. The `ANONYMOUS` auth profile is what + * states that — it forbids credentials outright, so a future change that starts + * sending the session cookie on a public read fails the profile check rather + * than silently making the cache-friendly surface user-specific. + */ +function publicRead( + operationId: string, + pathTemplate: string, + responseByteLimit: number, + project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () => + Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }), +): InstalledHttpContract { + return Object.freeze({ + contract: Object.freeze({ + operationId, + method: "GET" as const, + pathTemplate, + inputValidator: passthroughInput(`${operationId}Input`), + outputValidator: envelopeData(`${operationId}Output`), + problemValidator: PROBLEM, + // 404 is a normal answer here — a slug that is not published — so it is + // mapped by the gateway rather than treated as a transport failure. + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: never) { + return Object.freeze({ ...project(input), body: null }); + }, + }), + frontend: Object.freeze({ + policyId: `${operationId}_V1`, + requestByteLimit: 0, + responseByteLimit, + totalDeadlineMs: 10_000, + retryBudget: 2 as const, + authProfileId: "ANONYMOUS", + diagnosticsOperation: `techLog.public.${operationId}`, + }), + }) as InstalledHttpContract; +} + +const bySlug = (input: never) => { + const value = input as unknown as Readonly<{ slug: string }>; + return Object.freeze({ + pathValues: Object.freeze({ slug: value.slug }), + queryEntries: NO_QUERY, + }); +}; + +const P = "/api/v1/public"; + +const HTTP_CONTRACTS = Object.freeze([ + publicRead("getPublicSite", `${P}/site`, 32_768), + publicRead("getPublicHome", `${P}/home`, 262_144), + publicRead("exploreKnowledge", `${P}/explore/knowledge`, 262_144, (input: never) => { + const value = input as unknown as Readonly<{ + type?: string; + topic?: string; + project?: string; + page?: number; + size?: number; + }>; + return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) }); + }), + publicRead("exploreQuestions", `${P}/explore/questions`, 262_144, (input: never) => { + const value = input as unknown as Readonly<{ + status?: string; + topic?: string; + project?: string; + page?: number; + size?: number; + }>; + return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) }); + }), + publicRead("listPublicTopics", `${P}/topics`, 65_536), + publicRead("getPublicTopic", `${P}/topics/{topicSlug}`, 262_144, (input: never) => { + const value = input as unknown as Readonly<{ topicSlug: string }>; + return Object.freeze({ + pathValues: Object.freeze({ topicSlug: value.topicSlug }), + queryEntries: NO_QUERY, + }); + }), + publicRead("getPublicCase", `${P}/cases/{slug}`, 524_288, bySlug), + publicRead("getPublicReference", `${P}/references/{slug}`, 524_288, bySlug), + publicRead("getPublicQuestion", `${P}/questions/{slug}`, 524_288, bySlug), + publicRead("listPublicProjects", `${P}/projects`, 262_144), + publicRead("getPublicProject", `${P}/projects/{slug}`, 262_144, bySlug), + publicRead("listPublicProjectDecisions", `${P}/projects/{slug}/decisions`, 262_144, bySlug), + publicRead("listPublicProjectRecords", `${P}/projects/{slug}/records`, 262_144, bySlug), + publicRead("listPublicProjectActivities", `${P}/projects/{slug}/activities`, 262_144, bySlug), + publicRead("listPublicReleases", `${P}/releases`, 262_144), + publicRead("getPublicRelease", `${P}/releases/{version}`, 262_144, (input: never) => { + const value = input as unknown as Readonly<{ version: string }>; + return Object.freeze({ + pathValues: Object.freeze({ version: value.version }), + queryEntries: NO_QUERY, + }); + }), + publicRead("getPublicProfile", `${P}/profile`, 65_536), + publicRead("searchPublicResources", `${P}/search`, 262_144, (input: never) => { + const value = input as unknown as Readonly<{ q?: string; page?: number; size?: number }>; + return Object.freeze({ pathValues: NO_PATH, queryEntries: queryOf(value ?? {}) }); + }), +]); + +export const TECH_LOG_PUBLIC_OPERATION_IDS = Object.freeze( + HTTP_CONTRACTS.map((entry) => entry.contract.operationId), +); + +export type TechLogPublicOperationId = + (typeof TECH_LOG_PUBLIC_OPERATION_IDS)[number]; + +export const TECH_LOG_PUBLIC_CONTRIBUTION: InstalledContractContribution = + Object.freeze({ + contributionId: "tech-log-public-http-v1", + featureId: TECH_LOG_FEATURE_ID, + source: Object.freeze({ + kind: "EXTERNAL_PACKAGE" as const, + package: Object.freeze({ + packageId: canonicalSource.packageId, + version: canonicalSource.version, + digest: canonicalSource.digest as `sha256:${string}`, + runtimeProtocolVersion: 1 as const, + sourceRevision: canonicalSource.sourceRevision, + }), + }), + http: HTTP_CONTRACTS, + events: Object.freeze([]), + }); diff --git a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts index 5a02f26..cfb20ee 100644 --- a/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts @@ -52,6 +52,12 @@ function zodValidator(schemaId: string, schema: z.ZodType): RuntimeValidat const passthrough = (schemaId: string) => zodValidator(schemaId, z.unknown() as unknown as z.ZodType); +/** + * Shared with the public contribution: both surfaces project their request + * inputs in code, so neither re-validates them at the transport boundary. + */ +export const passthroughInput = passthrough; + /** * wire format은 봉투다 (ADR-006). 전송 계층은 봉투 뼈대만 검증하고 payload는 * 통과시킨다 — generated 타입이 컴파일 시점 계약이고, 런타임 재검증은 계약 갱신