// scripts/ci/lib/portable-alias-plan.mjs // *-latest 별칭 게시의 순수 정책 (IO 없음). 유스케이스는 ./portable-publish-policy.mjs. // // 별칭 교체 원칙 // - 패키지 버전 전체를 지우지 않는다. 바뀐 파일만 파일 단위로 DELETE→PUT 한다 // (Forgejo는 같은 이름 덮어쓰기를 409로 거부한다). 404 구간은 파일 하나를 바꾸는 수 초뿐이다. // - 인덱스(runtime.json / portable.json)는 항상 마지막에 바꾼다 — 인덱스 교체가 커밋 지점이다. // 새 부품을 올리는 동안에는 옛 인덱스와 옛 부품이 그대로 살아 있다. // - 새 인덱스가 올라간 뒤에만 별칭 집합에 없는 파일(이전 버전 부품, 예전에 별칭에 올리던 볼륨)을 정리한다. // - 별칭에는 클라이언트가 별칭 경로로 직접 받는 파일만 둔다. 버전 경로 URL로 참조되는 부품은 올리지 않는다. /** * @typedef {import("./portable-publish-policy.mjs").HashedPayload} HashedPayload * @typedef {{ item: HashedPayload, replace: boolean }} AliasWrite * @typedef {{ action: "skip" | "update", writes: AliasWrite[], prunes: string[] }} AliasPlan */ export class AliasSelectionError extends Error { /** @param {string} message */ constructor(message) { super(message); this.name = "AliasSelectionError"; } } /** * 별칭에 올릴 항목을 고른다. aliasNames가 없으면 모든 payload(하위 호환). * payload 순서를 유지하되 인덱스는 맨 뒤로 보낸다(커밋 지점). * * @param {{ items: readonly HashedPayload[], indexName: string, aliasNames?: readonly string[] }} input * @returns {HashedPayload[]} */ export function selectAliasItems({ items, indexName, aliasNames }) { let selected = [...items]; if (aliasNames !== undefined) { const wanted = new Set(aliasNames); const known = new Set(items.map((item) => item.name)); const unknown = [...wanted].filter((name) => !known.has(name)); if (unknown.length > 0) { throw new AliasSelectionError(`별칭 파일이 게시 payload에 없습니다: ${unknown.join(", ")}`); } if (!wanted.has(indexName)) { throw new AliasSelectionError(`별칭에는 인덱스(${indexName})가 있어야 합니다.`); } selected = items.filter((item) => wanted.has(item.name)); } return [ ...selected.filter((item) => item.name !== indexName), ...selected.filter((item) => item.name === indexName), ]; } /** * 별칭 파일 단위 교체 계획. * - writes: 원격과 바이트가 다른 항목(항목 순서 유지). replace=true면 같은 이름이 원격에 있어 먼저 지워야 한다. * - prunes: 원격 별칭에만 있는 파일. 모든 writes(인덱스 포함)가 끝난 뒤에 지운다. * * @param {{ items: readonly HashedPayload[], remoteHashes: ReadonlyMap }} input * @returns {AliasPlan} */ export function planAliasFiles({ items, remoteHashes }) { const writes = items .filter((item) => remoteHashes.get(item.name) !== item.sha256) .map((item) => ({ item, replace: remoteHashes.has(item.name) })); const keep = new Set(items.map((item) => item.name)); const prunes = [...remoteHashes.keys()].filter((name) => !keep.has(name)); const action = writes.length > 0 || prunes.length > 0 ? "update" : "skip"; return { action, writes, prunes }; } /** * 버전 경로가 이미 완성돼 있는데(재빌드 바이트가 달라 버전 단계가 abort) 별칭을 그 게시본으로 * 복구할 수 있는지 판단한다. 원격 버전 경로의 바이트가 정본이다. * - 별칭 항목이 버전 경로에 하나라도 없으면 복구 불가(버전 경로 미완성). * - 별칭에 이미 같은 sha256으로 있는 항목은 그대로 둔다(retain). * - 다른 항목은 텍스트(인덱스·설치 스크립트)일 때만 버전 경로에서 읽어 옮길 수 있다(reads). * 이진 부품이 어긋나 있으면 복구 불가. * * @param {{ * aliasItems: readonly HashedPayload[], * versionedHashes: ReadonlyMap, * aliasHashes: ReadonlyMap, * }} input * @returns {{ restorable: false, reason: string } | * { restorable: true, reads: Array<{ name: string, contentType: string, sha256: string }>, prunes: string[] }} */ export function planAliasRestore({ aliasItems, versionedHashes, aliasHashes }) { const missing = aliasItems.filter((item) => !versionedHashes.has(item.name)); if (missing.length > 0) { return { restorable: false, reason: `버전 경로에 없음: ${missing.map((item) => item.name).join(", ")}` }; } /** @type {Array<{ name: string, contentType: string, sha256: string }>} */ const reads = []; for (const item of aliasItems) { const sha256 = /** @type {string} */ (versionedHashes.get(item.name)); if (aliasHashes.get(item.name) === sha256) continue; if (!isTextContentType(item.contentType)) { return { restorable: false, reason: `이진 파일은 게시본에서 옮길 수 없음: ${item.name}` }; } reads.push({ name: item.name, contentType: item.contentType, sha256 }); } const keep = new Set(aliasItems.map((item) => item.name)); const prunes = [...aliasHashes.keys()].filter((name) => !keep.has(name)); return { restorable: true, reads, prunes }; } /** @param {string} contentType */ export function isTextContentType(contentType) { return contentType === "application/json" || contentType.startsWith("text/"); }