feat: let Studio create the topics and projects publishing requires

Publishing needs a topic and nothing could create one. The backend now owns
that surface; this is its consumer — the management contract vendored, a
gateway over its nine operations, and one Studio screen that lists, creates,
and deletes topics and projects.

The screen adds no CSS. It reuses the classes the working-copy list already
uses, so it inherits Studio's spacing, type, and colour rather than growing a
second visual vocabulary beside them. Scope stops at list/create/delete:
renaming, phase changes, and visibility are implemented in the backend and
declared in the contract, but their screens are a separate design.

Two real defects surfaced while making the public port async, and both would
have shipped:

The search page and the header search dialog shared a query key. With an empty
query, `["tech-log","search",""]` was identical for both, so react-query
handed one surface the other's cache — different shapes — and the page died
reading a field that was not there. Keys now name the surface.

The explore filter's selects are uncontrolled and read `defaultValue`, which
React applies once. Their options arrive later now, so the first render had
nothing to match and the value stayed empty: a topic in the URL no longer
showed as selected. The form key includes whether the catalog has arrived, so
it remounts with the options present. Controlled inputs would be the other
answer, but this form submits to build a URL — the URL owns the value.

The route brought its own bookkeeping: a build chunk, a manual accessibility
evidence file, and the CI artifact baseline that counts them. The gate pins a
digest of its own shape precisely so a new route cannot slip in without that
count being reviewed.

Test harnesses that render public screens now assemble the query providers and
await the settled paint, because the screens they render became async.
This commit is contained in:
DongHyeonka
2026-08-20 23:40:15 +09:00
parent 4b62bf3b1f
commit 11c2713139
52 changed files with 16509 additions and 134 deletions
@@ -0,0 +1,87 @@
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f",
"sourceRevision": "6ef5c1c",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
"updateCaseDraft",
"deleteCaseDraft",
"createReferenceDraft",
"getReferenceForEdit",
"updateReferenceDraft",
"deleteReferenceDraft",
"validateCase",
"submitReviewCase",
"returnToDraftCase",
"unpublishCase",
"archiveCase",
"restoreCase",
"publishCase",
"validateReference",
"submitReviewReference",
"returnToDraftReference",
"unpublishReference",
"archiveReference",
"restoreReference",
"publishReference",
"createQuestion",
"listStudioQuestions",
"getQuestionForEdit",
"updateQuestion",
"deleteQuestion",
"addQuestionUpdate",
"updateQuestionUpdate",
"deleteQuestionUpdate",
"resolveQuestion",
"startQuestionInvestigation",
"pauseQuestion",
"resumeQuestion",
"reopenQuestion",
"archiveQuestion",
"publishQuestion",
"unpublishQuestion",
"createProject",
"listStudioProjects",
"getProjectForEdit",
"updateProject",
"deleteProject",
"changeProjectPhase",
"publishProject",
"unpublishProject",
"createProjectDecision",
"listStudioProjectDecisions",
"getProjectDecision",
"updateProjectDecision",
"acceptProjectDecision",
"rejectProjectDecision",
"supersedeProjectDecision",
"createRelease",
"listStudioReleases",
"getReleaseForEdit",
"updateRelease",
"deleteRelease",
"publishRelease",
"archiveRelease",
"listStudioTopics",
"createTopic",
"updateTopic",
"deleteTopic",
"listStudioTags",
"createTag",
"updateTag",
"deleteTag",
"getStudioSite",
"updateStudioSite",
"getStudioProfile",
"updateStudioProfile",
"publishProfile",
"unpublishProfile",
"getHomeFocus",
"updateHomeFocus",
"listStudioProjectActivities",
"createProjectActivity",
"updateProjectActivity"
]
}
@@ -0,0 +1,12 @@
import type { components } from "./generated.ts";
type Schemas = components["schemas"];
export type TopicEdit = Schemas["TopicEdit"];
export type ProjectEditResponse = Schemas["ProjectEditResponse"];
export type ProjectIndexItem = Schemas["ProjectIndexItem"];
export type ProjectIndexPage = Schemas["ProjectIndexPage"];
export type ProjectUpdateRequest = Schemas["ProjectUpdateRequest"];
export type CreateDraftRequest = Schemas["CreateDraftRequest"];
export type CreateDraftResponse = Schemas["CreateDraftResponse"];
export type ExpectedVersionRequest = Schemas["ExpectedVersionRequest"];
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
"packageId": "@tech-log/public-contract",
"version": "2.0.0",
"digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e",
"sourceRevision": "b98eaf9",
"sourceRevision": "6ef5c1c",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -2,7 +2,7 @@
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4",
"sourceRevision": "b98eaf9",
"sourceRevision": "6ef5c1c",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -0,0 +1,256 @@
import type {
CommandEffectDescriptor,
InstalledContractContribution,
InstalledHttpContract,
} from "../../../contracts/external-contract-runtime.ts";
import type { ProblemDetails } from "./studio/contract.ts";
import { TECH_LOG_FEATURE_ID } from "../application/tech-log-feature-input.ts";
import canonicalSource from "./management/canonical-source.json" with { type: "json" };
import {
envelopeData,
envelopeError,
passthroughInput,
} from "./tech-log-studio-contract-contribution.ts";
import { TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID } from "../adapters/http/studio-session-credentials.ts";
type PathValues = Readonly<Record<string, string>>;
type QueryEntries = readonly (readonly [string, string])[];
const NO_PATH: PathValues = Object.freeze({});
const NO_QUERY = Object.freeze([]) as QueryEntries;
const PROBLEM = envelopeError();
/** Studio 쪽과 같은 판정이다: 4xx 도메인 거절은 적용되지 않았음이 확정, 5xx·네트워크는 불확정. */
const COMMAND_EFFECT: CommandEffectDescriptor<ProblemDetails> = Object.freeze({
successEffect: "APPLIED_CONFIRMED" as const,
classifyProblem({ status }: Readonly<{ status: number; problem: unknown }>) {
return status >= 400 && status < 500 ? "NOT_APPLIED" : "MAYBE_APPLIED";
},
});
/**
* 관리 표면은 Studio 와 같은 세션·CSRF 를 쓴다. 계약이 `sessionCookie` 보안과 `CsrfToken`
* 파라미터를 선언하고 있고, 실제로 같은 백엔드의 같은 필터 체인을 지난다 — 그래서 인증 프로필도
* 공유한다. 부트스트랩 프로필은 쓰지 않는다: CSRF 토큰을 발급하는 것은 `getStudioSession`
* 하나뿐이고, 이 표면은 그 뒤에만 호출된다.
*/
function readOperation(
operationId: string,
pathTemplate: string,
responseByteLimit: number,
project: (input: never) => Readonly<{ pathValues: PathValues; queryEntries: QueryEntries }> = () =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY }),
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method: "GET" as const,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
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: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
diagnosticsOperation: `techLog.management.${operationId}`,
}),
}) as InstalledHttpContract<unknown, unknown, unknown>;
}
/**
* 쓰기는 `Idempotency-Key` 를 쓰지 않는다 — 계약이 요구하지 않고, 재생 보호는 `expectedVersion`
* 이 맡는다. 그래서 `NOT_IDEMPOTENT` 가 아니라 재시도 예산 0 으로 둔다: 응답을 못 본 재시도가
* 두 번째 생성을 만들 수 있는 표면이다.
*/
function writeOperation(
operationId: string,
method: "POST" | "PUT" | "DELETE",
pathTemplate: string,
options: Readonly<{
acceptedStatuses: readonly number[];
emptyBodyStatuses?: readonly number[];
requestByteLimit: number;
responseByteLimit: number;
}>,
project: (input: never) => Readonly<{
pathValues: PathValues;
queryEntries: QueryEntries;
body: unknown;
}>,
): InstalledHttpContract<unknown, unknown, unknown> {
return Object.freeze({
contract: Object.freeze({
operationId,
method,
pathTemplate,
inputValidator: passthroughInput(`${operationId}Input`),
outputValidator: envelopeData(`${operationId}Output`),
problemValidator: PROBLEM,
acceptedStatuses: Object.freeze([...options.acceptedStatuses]),
emptyBodyStatuses: Object.freeze([...(options.emptyBodyStatuses ?? [])]),
// 생성은 재생 보호가 없으므로 NEVER 다. 수정·삭제는 expectedVersion 이 두 번째
// 적용을 409 로 막으므로 IDEMPOTENT 로 둘 수 있지만, 세 경우를 한 헬퍼가 만들고
// 있어 가장 보수적인 값으로 통일한다 — 재시도 예산도 0 이라 실제 차이는 없다.
retrySemantics: "NEVER" as const,
requestBody: "JSON" as const,
responseBody:
(options.emptyBodyStatuses ?? []).length > 0
? ("OPTIONAL_JSON" as const)
: ("REQUIRED_JSON" as const),
commandRecovery: null,
commandEffect: COMMAND_EFFECT,
projectRequest(input: never) {
return Object.freeze(project(input));
},
}),
frontend: Object.freeze({
policyId: `${operationId}_V1`,
requestByteLimit: options.requestByteLimit,
responseByteLimit: options.responseByteLimit,
totalDeadlineMs: 15_000,
retryBudget: 0 as const,
authProfileId: TECH_LOG_STUDIO_SESSION_AUTH_PROFILE_ID,
diagnosticsOperation: `techLog.management.${operationId}`,
}),
// read 쪽과 달리 여기서만 unknown 을 거친다: `emptyBodyStatuses` 유무로 responseBody 가
// 갈리는 삼항이 union 타입을 만들어, 컴파일러가 리터럴을 대상 타입과 겹친다고 보지 않는다.
}) as unknown as InstalledHttpContract<unknown, unknown, unknown>;
}
const byId = (input: never) => {
const value = input as unknown as Readonly<{ id: string }>;
return Object.freeze({ pathValues: Object.freeze({ id: value.id }), queryEntries: NO_QUERY });
};
const T = "/api/v1/studio/topics";
const P = "/api/v1/studio/projects";
const HTTP_CONTRACTS = Object.freeze([
readOperation("listStudioTopics", T, 131_072),
writeOperation(
"createTopic",
"POST",
T,
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"updateTopic",
"PUT",
`${T}/{id}`,
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteTopic",
"DELETE",
`${T}/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("listStudioProjects", P, 262_144, (input: never) => {
const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined;
const entries: (readonly [string, string])[] = [];
if (value?.page !== undefined) entries.push(["page", String(value.page)]);
if (value?.size !== undefined) entries.push(["size", String(value.size)]);
return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) });
}),
readOperation("getProjectForEdit", `${P}/{id}`, 262_144, byId),
writeOperation(
"createProject",
"POST",
P,
{ acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"updateProject",
"PUT",
`${P}/{id}`,
{ acceptedStatuses: [200], requestByteLimit: 131_072, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteProject",
"DELETE",
`${P}/{id}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
]);
export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze(
HTTP_CONTRACTS.map((entry) => entry.contract.operationId),
);
export const TECH_LOG_MANAGEMENT_CONTRIBUTION: InstalledContractContribution = Object.freeze({
contributionId: "tech-log-management-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([]),
});
@@ -48,6 +48,7 @@ const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const;