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.
185 lines
7.2 KiB
TypeScript
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` 게이트가 잡는다.
|