Files
tech-log-frontend/scripts/generate-tech-log-contract.ts
T
DongHyeonka 11c2713139 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.
2026-08-20 23:40:15 +09:00

193 lines
7.6 KiB
TypeScript

/**
* canonical 계약(studio-v1, public-v1)을 vendor하고 타입을 생성한다.
*
* 생성기는 저장소 의존성에 넣지 않는다. `openapi-typescript`는 TypeScript 5의
* classic compiler API를 요구하는데 이 저장소는 TypeScript 7.0.2를 고정하고
* 있고(VD-01), TS7 루트는 compiler API를 노출하지 않는다. 격리된 `pnpm dlx`
* 환경에서 실행하면 lockfile과 peer 계약을 건드리지 않고 같은 산출물을 얻는다.
*
* `--check`는 canonical 저장소도 생성기도 없이 동작한다. vendor된 계약이
* 기록된 digest와 일치하는지, 기록된 operationId가 생성물에 모두 존재하는지만
* 본다. 손으로 yaml이나 generated.ts를 고치면 여기서 걸린다.
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { argv, env, exit } from "node:process";
const CANONICAL_ROOT =
env.TECH_LOG_DESIGN_PACKAGE ?? "/home/donghyeon/workspace/tech-log-design-package";
/**
* 계약은 둘이고 서로 독립이다. Studio는 인증된 작성 표면이고, Public은 인증
* 없는 조회 표면이다. 각자 자기 canonical yaml에서 나오고 자기 digest를 들고
* 다니므로, 한쪽이 갱신돼도 다른 쪽 drift 게이트는 조용하다.
*/
type ContractTarget = Readonly<{
name: string;
packageId: string;
canonicalYaml: string;
vendorYaml: string;
generated: string;
sourceRecord: string;
}>;
const CONTRACTS: readonly ContractTarget[] = Object.freeze([
Object.freeze({
name: "studio",
packageId: "@tech-log/studio-contract",
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-v1.yaml`,
vendorYaml: "src/features/tech-log/contracts/studio/studio-api.openapi.yaml",
generated: "src/features/tech-log/contracts/studio/generated.ts",
sourceRecord: "src/features/tech-log/contracts/studio/canonical-source.json",
}),
Object.freeze({
name: "public",
packageId: "@tech-log/public-contract",
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/public-v1.yaml`,
vendorYaml: "src/features/tech-log/contracts/public/public-api.openapi.yaml",
generated: "src/features/tech-log/contracts/public/generated.ts",
sourceRecord: "src/features/tech-log/contracts/public/canonical-source.json",
}),
Object.freeze({
name: "management",
packageId: "@tech-log/management-contract",
canonicalYaml: `${CANONICAL_ROOT}/contracts/openapi/studio-management-v1.yaml`,
vendorYaml: "src/features/tech-log/contracts/management/management-api.openapi.yaml",
generated: "src/features/tech-log/contracts/management/generated.ts",
sourceRecord: "src/features/tech-log/contracts/management/canonical-source.json",
}),
]);
const OPENAPI_TYPESCRIPT = "openapi-typescript@7.9.1";
const GENERATOR_TYPESCRIPT = "typescript@5.9.3";
const check = argv.includes("--check");
function digestOf(bytes: Buffer | string): string {
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
}
function operationIdsOf(yaml: string): string[] {
return [...yaml.matchAll(/^\s+operationId:\s*(\S+)\s*$/gmu)].map((match) => match[1]!);
}
function specVersionOf(yaml: string): string {
const match = /^\s{2}version:\s*(\S+)\s*$/mu.exec(yaml);
if (!match) throw new Error("canonical yaml has no info.version");
return match[1]!;
}
type CanonicalRecord = Readonly<{
packageId: string;
version: string;
digest: string;
sourceRevision: string;
operationIds: readonly string[];
}>;
function fail(problems: readonly string[]): never {
console.error(`tech-log contract drift:\n- ${problems.join("\n- ")}`);
console.error("Run: corepack pnpm generate:tech-log-contract");
exit(1);
}
if (check) {
const problems: string[] = [];
const summaries: string[] = [];
for (const target of CONTRACTS) {
const vendored = readFileSync(target.vendorYaml, "utf8");
const generated = readFileSync(target.generated, "utf8");
const record = JSON.parse(readFileSync(target.sourceRecord, "utf8")) as CanonicalRecord;
if (digestOf(readFileSync(target.vendorYaml)) !== record.digest) {
problems.push(`${target.vendorYaml} does not hash to the recorded digest`);
}
if (operationIdsOf(vendored).join(" ") !== [...record.operationIds].join(" ")) {
problems.push(`${target.sourceRecord} operationIds differ from ${target.vendorYaml}`);
}
if (specVersionOf(vendored) !== record.version) {
problems.push(`${target.sourceRecord} version differs from ${target.vendorYaml}`);
}
if (record.packageId !== target.packageId) {
problems.push(`${target.sourceRecord} packageId is not ${target.packageId}`);
}
// 생성물은 operationId로 키가 매겨진 `operations` 인터페이스를 노출한다.
for (const operationId of record.operationIds) {
if (!new RegExp(`^\\s{4}${operationId}:`, "mu").test(generated)) {
problems.push(`${target.generated} is missing operation ${operationId}`);
}
}
summaries.push(
`${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations`,
);
}
if (problems.length > 0) fail(problems);
console.log(`tech-log contracts are in sync:\n- ${summaries.join("\n- ")}`);
exit(0);
}
const sourceRevision = execFileSync(
"git",
["-C", CANONICAL_ROOT, "rev-parse", "--short=7", "HEAD"],
{ encoding: "utf8" },
).trim();
for (const target of CONTRACTS) {
const canonicalBytes = readFileSync(target.canonicalYaml);
const canonicalText = canonicalBytes.toString("utf8");
const record: CanonicalRecord = {
packageId: target.packageId,
version: specVersionOf(canonicalText),
digest: digestOf(canonicalBytes),
sourceRevision,
operationIds: operationIdsOf(canonicalText),
};
// 격리 실행. 저장소의 node_modules와 lockfile은 그대로다.
const generated = execFileSync(
"corepack",
[
"pnpm",
"dlx",
"--package",
GENERATOR_TYPESCRIPT,
"--package",
OPENAPI_TYPESCRIPT,
"openapi-typescript",
target.canonicalYaml,
],
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
);
mkdirSync(dirname(target.vendorYaml), { recursive: true });
writeFileSync(target.vendorYaml, canonicalText);
writeFileSync(target.generated, generated);
writeFileSync(target.sourceRecord, `${JSON.stringify(record, null, 2)}\n`);
console.log(
`Generated from ${record.packageId}@${record.version} (${record.sourceRevision}), ${record.operationIds.length} operations.`,
);
}
// 재생성은 매번 package digest를 바꾼다. `pnpm dev`가 그대로 서빙하는
// `public/release-manifest.json`은 build가 컴파일한 contract set을 그대로
// 선언해야 하고(§5.5, `verifyContractSet`는 MOCK 모드에서도 무조건 돈다),
// 그러지 않으면 dev 부팅이 CONTRACT_SET_PACKAGE_MISSING으로 닫힌다.
// 방금 쓴 canonical-source.json을 읽어야 하므로 정적 import가 아닌 동적
// import로 불러온다.
const { refreshDevReleaseManifestContractSet, DEV_RELEASE_MANIFEST_PATH } =
await import("./lib/dev-release-manifest.ts");
const refreshed = await refreshDevReleaseManifestContractSet();
console.log(
`${refreshed.changed ? "Updated" : "Already in sync"}: ${DEV_RELEASE_MANIFEST_PATH} contractSet ` +
`(${refreshed.contractSet.packages.length} package(s), ${refreshed.contractSet.setDigest})`,
);
// contract를 다시 만들지 않아도 구성된 set은 바뀔 수 있다
// (`installed-contract-contributions.ts`에 기여가 추가/제거되는 경우).
// 그 경로는 여기서 못 잡으므로 `check:dev-release-manifest` 게이트가 잡는다.