Files
tech-log-frontend/scripts/generate-tech-log-contract.ts
T
DongHyeonka c362ec6100 feat: vendor the public read contract, and give it its own source switch
The public surface — 17 of the 28 registered routes — reads from a 29KB
TypeScript fixture and never touches the backend. `TECH_LOG_STUDIO_SOURCE`
only ever switched the Studio gateways; `publicContent` was wired to the
static adapter unconditionally, so no configuration could make the public
site show published content. This is the first half of closing that: the
contract and the switch, with the adapter still to come.

The generator now vendors both canonical contracts instead of one. They are
independent — different services on different schedules — so each carries
its own digest and operation list, and updating one leaves the other's drift
gate quiet.

`TECH_LOG_PUBLIC_SOURCE` is deliberately a second flag rather than a rename
of the Studio one. The combination that matters right now is exactly the one
a single flag cannot express: the authoring backend is live while the public
read API does not exist yet. production stays on MOCK for that reason —
pointing it at HTTP today would empty the live site — and moves when the
backend serves /api/v1/public.

Also records the compatibility evidence the registry gate wanted for the
Studio access change in fff5e6f. That gate has been failing since, which is
on me: the change was real and breaking, and it shipped without the note
explaining that route ids and schemas are untouched and only the access
classification moves.
2026-08-20 16:19:53 +09:00

185 lines
7.2 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",
}),
]);
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` 게이트가 잡는다.