feat: give the public surface an HTTP adapter, and a switch to reach it
The public read port had one implementation and no way to add another. This is the second one: the 18 operations of the public contract, mapped to the nine methods the screens call. The contract and the screens disagree about shape, and translating here is what keeps the presentation components untouched. The server speaks in what it stores — timestamps, one markdown body, relations grouped by why they relate. The screens were built against a catalog that spoke in what a page renders — formatted labels, titled sections, one flat relation list whose group name is the reason. Neither is wrong. Where the contract has no counterpart the value is left empty and the gap is named where it happens rather than guessed at: a Case's verification line, a decision's consequences, a question's options. Sections are split from markdown here rather than through the Studio parser. That parser produces the canonical render-block union the editor needs — inline marks, evidence directives, tables — which is a richer tree than RecordSection can hold, so reusing it would mean flattening away exactly the blocks that made it worth using. A 404 is unwrapped, not thrown. A slug that is not published is an answer the port already has a shape for, and throwing would put a terminal-error surface on a page whose real state is "this does not exist". `listRecords` is one method over two endpoints, because the contract pages and filters knowledge separately from questions. Only the unfiltered call fans out: asking for one kind must not pay for the other. The operations declare the ANONYMOUS auth profile, which forbids credentials outright. That is the point — a later change that starts sending the session cookie on a public read fails the profile check instead of quietly making a cache-friendly surface user-specific.
This commit is contained in:
@@ -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<T>(operationId: string, input: unknown): Promise<T | typeof NOT_FOUND> {
|
||||
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<T>(operationId: string, input: unknown): Promise<T> {
|
||||
const value = await read<T>(operationId, input);
|
||||
if (value === NOT_FOUND) throw gatewayError(operationId, "NOT_FOUND");
|
||||
return value;
|
||||
}
|
||||
|
||||
type Page = Readonly<{ items?: readonly Readonly<Record<string, unknown>>[] }>;
|
||||
|
||||
/**
|
||||
* `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<PublicRecord[]> {
|
||||
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<Page>("exploreKnowledge", {
|
||||
...query,
|
||||
...(filters.kind && filters.kind !== "QUESTION" ? { type: filters.kind } : {}),
|
||||
})
|
||||
: Promise.resolve({ items: [] } as Page),
|
||||
wantsQuestions
|
||||
? readOrThrow<Page>("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<PublicRecord, {kind: K}>`
|
||||
* 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<K extends RecordKind>(
|
||||
kind: K,
|
||||
slug: string,
|
||||
): Promise<Extract<PublicRecord, { kind: K }> | undefined> {
|
||||
const operationId =
|
||||
kind === "CASE" ? "getPublicCase" : kind === "REFERENCE" ? "getPublicReference" : "getPublicQuestion";
|
||||
const detail = await read<Readonly<Record<string, unknown>>>(operationId, { slug });
|
||||
if (detail === NOT_FOUND) return undefined;
|
||||
|
||||
const canonicalPath = String(detail.canonicalPath ?? "");
|
||||
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
|
||||
|
||||
if (kind === "CASE") {
|
||||
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
|
||||
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<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
if (kind === "REFERENCE") {
|
||||
const body = (detail.reference as Readonly<Record<string, unknown>>) ?? {};
|
||||
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<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
|
||||
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | 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<PublicRecord, { kind: K }>;
|
||||
}
|
||||
|
||||
async function getProject(slug: string): Promise<Project | undefined> {
|
||||
const detail = await read<Readonly<Record<string, unknown>>>("getPublicProject", { slug });
|
||||
if (detail === NOT_FOUND) return undefined;
|
||||
const body = (detail.project as Readonly<Record<string, unknown>>) ?? {};
|
||||
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<ProjectDecision[]> {
|
||||
const page = await read<Page>("listPublicProjectDecisions", { slug: projectSlug });
|
||||
if (page === NOT_FOUND) return [];
|
||||
return (page.items ?? []).map(decisionItemToDecision);
|
||||
}
|
||||
|
||||
async function getProjectActivity(projectSlug: string): Promise<ProjectActivity[]> {
|
||||
const page = await read<Page>("listPublicProjectActivities", { slug: projectSlug });
|
||||
if (page === NOT_FOUND) return [];
|
||||
return (page.items ?? []).map(activityItemToActivity);
|
||||
}
|
||||
|
||||
async function getProjectRecords(projectSlug: string): Promise<PublicRecord[]> {
|
||||
const page = await read<Page>("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<Release | undefined> {
|
||||
const detail = await read<Readonly<Record<string, unknown>>>("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<HomeFocusItem[]> {
|
||||
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
||||
"getPublicHome",
|
||||
{},
|
||||
);
|
||||
if (home === NOT_FOUND) return [];
|
||||
const focus = (home.focus ?? {}) as Readonly<Record<string, Readonly<Record<string, unknown>>>>;
|
||||
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<SearchablePublicEntity[]> {
|
||||
const page = await read<Page>("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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user