forked from phenom/healer-man
558 lines
24 KiB
JavaScript
558 lines
24 KiB
JavaScript
import { createHash, randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { resolveArchiveDescriptions } from "./flight-text.mjs";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const COA_ARCHIVE = Object.freeze({
|
|
repository: "srhinos/coa-datamine",
|
|
revision: "5078f9ac22541c45c17a866c23aac6c12073de23",
|
|
shortRevision: "5078f9ac2254",
|
|
capturedAt: "2026-08-12T21:02:51+00:00",
|
|
fetchManifestPath: "raw/talents/_fetch.json",
|
|
builderSnapshotPath: "raw/talents/coa-builder-voljin.html",
|
|
builderSnapshotSha256: "41a6ccd393c0648ae2b1fdc34fe94955d4fd13c0cd59040ad3860e64a8ff6c7a",
|
|
});
|
|
|
|
const defaultProjectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
const expectedClassCount = 21;
|
|
const cacheSchemaVersion = 1;
|
|
const manifestName = "manifest.json";
|
|
|
|
function assertSafeRelativePath(value, context = "archive path") {
|
|
if (typeof value !== "string" || value.length === 0) {
|
|
throw new Error(`${context} must be a non-empty string`);
|
|
}
|
|
const normalized = value.replaceAll("\\", "/");
|
|
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
|
|
throw new Error(`${context} must be relative: ${value}`);
|
|
}
|
|
if (normalized.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
throw new Error(`${context} contains an unsafe path segment: ${value}`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function classFileFromDescriptor(descriptor) {
|
|
let candidate = null;
|
|
if (typeof descriptor === "string") {
|
|
candidate = descriptor;
|
|
} else if (descriptor && typeof descriptor === "object") {
|
|
candidate = descriptor.path ?? descriptor.file ?? descriptor.filename ?? descriptor.href ?? null;
|
|
if (candidate === null) candidate = descriptor.slug ?? descriptor.key ?? descriptor.id ?? null;
|
|
}
|
|
if (candidate === null || candidate === undefined) {
|
|
throw new Error("CoA talent class index contains an entry without a file path or class key");
|
|
}
|
|
|
|
const relative = assertSafeRelativePath(String(candidate), "CoA talent class file reference");
|
|
let filePath;
|
|
if (relative.startsWith("data/talents/coa/classes/")) {
|
|
filePath = relative;
|
|
} else if (relative.startsWith("classes/")) {
|
|
filePath = `data/talents/coa/${relative}`;
|
|
} else if (relative.startsWith("data/talents/coa/") && !relative.slice("data/talents/coa/".length).includes("/")) {
|
|
filePath = relative;
|
|
} else if (relative.includes("/")) {
|
|
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
|
|
} else {
|
|
filePath = `data/talents/coa/${relative.endsWith(".json") ? relative : `${relative}.json`}`;
|
|
}
|
|
if (!filePath.startsWith("data/talents/coa/") || !filePath.endsWith(".json")
|
|
|| filePath === "data/talents/coa/index.json"
|
|
|| filePath === "data/talents/coa/_meta.json") {
|
|
throw new Error(`CoA talent class file is outside the indexed CoA talent data: ${candidate}`);
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
function isClassArchivePath(filePath) {
|
|
return filePath.startsWith("data/talents/coa/")
|
|
&& filePath.endsWith(".json")
|
|
&& filePath !== "data/talents/coa/index.json"
|
|
&& filePath !== "data/talents/coa/_meta.json";
|
|
}
|
|
|
|
function descriptorsFromIndex(index) {
|
|
const entries = index?.classes;
|
|
if (Array.isArray(entries)) return entries;
|
|
if (entries && typeof entries === "object") {
|
|
return Object.entries(entries).map(([key, value]) => {
|
|
if (typeof value === "string") return value;
|
|
if (value && typeof value === "object") return { key, ...value };
|
|
return { key };
|
|
});
|
|
}
|
|
throw new Error("CoA talent index has no classes array or object");
|
|
}
|
|
|
|
function classPathsFromIndex(index) {
|
|
const descriptors = descriptorsFromIndex(index);
|
|
if (descriptors.length !== expectedClassCount) {
|
|
throw new Error(`CoA talent index expected ${expectedClassCount} class files, found ${descriptors.length}`);
|
|
}
|
|
const paths = descriptors.map(classFileFromDescriptor);
|
|
if (new Set(paths).size !== expectedClassCount) {
|
|
throw new Error("CoA talent index contains duplicate class file references");
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function sha256(bytes) {
|
|
return createHash("sha256").update(bytes).digest("hex");
|
|
}
|
|
|
|
function gitBlobSha(bytes) {
|
|
return createHash("sha1")
|
|
.update(Buffer.from(`blob ${bytes.byteLength}\0`, "utf8"))
|
|
.update(bytes)
|
|
.digest("hex");
|
|
}
|
|
|
|
function parseJson(bytes, filePath) {
|
|
try {
|
|
return JSON.parse(bytes.toString("utf8"));
|
|
} catch (error) {
|
|
throw new Error(`CoA archive JSON is invalid at ${filePath}: ${error.message}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
function isRegularBlob(entry) {
|
|
return entry?.type === "blob" && entry?.mode === "100644" && /^[0-9a-f]{40}$/i.test(entry.sha ?? "");
|
|
}
|
|
|
|
export function createSupplementCacheApi(archiveInput = COA_ARCHIVE) {
|
|
const archive = Object.freeze({ ...archiveInput });
|
|
for (const key of ["repository", "revision", "shortRevision", "capturedAt", "fetchManifestPath", "builderSnapshotPath", "builderSnapshotSha256"]) {
|
|
if (typeof archive[key] !== "string" || archive[key].length === 0) {
|
|
throw new TypeError(`archive.${key} must be a non-empty string`);
|
|
}
|
|
}
|
|
if (!/^[0-9a-f]{40}$/i.test(archive.revision) || !/^[0-9a-f]{7,40}$/i.test(archive.shortRevision)) {
|
|
throw new TypeError("archive revision values must be hexadecimal Git revisions");
|
|
}
|
|
if (!/^[0-9a-f]{64}$/i.test(archive.builderSnapshotSha256)) {
|
|
throw new TypeError("archive.builderSnapshotSha256 must be a SHA-256 hex digest");
|
|
}
|
|
|
|
const requiredPaths = [
|
|
archive.fetchManifestPath,
|
|
archive.builderSnapshotPath,
|
|
"data/talents/coa/index.json",
|
|
"data/talents/coa/_meta.json",
|
|
"raw/provenance.json",
|
|
"raw/_snapshot.json",
|
|
"data/spells/_meta.json",
|
|
];
|
|
|
|
function archiveDirectory(cacheRoot) {
|
|
if (typeof cacheRoot !== "string" || cacheRoot.length === 0) {
|
|
throw new TypeError("cacheRoot must be a non-empty directory path");
|
|
}
|
|
return path.join(path.resolve(cacheRoot), `archive-${archive.shortRevision}`);
|
|
}
|
|
|
|
function archiveUrls(filePath) {
|
|
const apiUrl = `https://api.github.com/repos/${archive.repository}/git/trees/${archive.revision}?recursive=1`;
|
|
const rawPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
const rawUrl = `https://raw.githubusercontent.com/${archive.repository}/${archive.revision}/${rawPath}`;
|
|
return { apiUrl, rawUrl };
|
|
}
|
|
|
|
function parseCaptureManifest(bytes) {
|
|
const capture = parseJson(bytes, archive.fetchManifestPath);
|
|
if (capture?.capturedUtc !== archive.capturedAt) {
|
|
throw new Error(`CoA capture manifest has an unexpected capture time: ${String(capture?.capturedUtc)}`);
|
|
}
|
|
if (capture?.savedAs !== path.posix.basename(archive.builderSnapshotPath)) {
|
|
throw new Error(`CoA capture manifest has an unexpected savedAs value: ${String(capture?.savedAs)}`);
|
|
}
|
|
if (capture?.sha256 !== archive.builderSnapshotSha256) {
|
|
throw new Error(`CoA capture manifest does not pin builder SHA-256 ${archive.builderSnapshotSha256}`);
|
|
}
|
|
return capture;
|
|
}
|
|
|
|
function sourceRecord() {
|
|
return {
|
|
repository: `https://github.com/${archive.repository}`,
|
|
revision: archive.revision,
|
|
capturedAt: archive.capturedAt,
|
|
captureManifestPath: archive.fetchManifestPath,
|
|
builderSnapshotPath: archive.builderSnapshotPath,
|
|
builderSnapshotSha256: archive.builderSnapshotSha256,
|
|
};
|
|
}
|
|
|
|
function validateManifestShape(manifest) {
|
|
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
throw new Error("CoA supplement manifest is not an object");
|
|
}
|
|
if (manifest.schemaVersion !== cacheSchemaVersion) {
|
|
throw new Error(`Unsupported CoA supplement cache schema: ${String(manifest.schemaVersion)}`);
|
|
}
|
|
if (manifest.source?.revision !== archive.revision) {
|
|
throw new Error("CoA supplement manifest points at an unexpected archive revision");
|
|
}
|
|
if (typeof manifest.generation !== "string" || !/^generation-[0-9a-f-]+$/.test(manifest.generation)) {
|
|
throw new Error("CoA supplement manifest has an invalid generation name");
|
|
}
|
|
if (!Array.isArray(manifest.files)) throw new Error("CoA supplement manifest has no file list");
|
|
const source = manifest.source;
|
|
if (source.captureManifestPath !== archive.fetchManifestPath
|
|
|| source.builderSnapshotPath !== archive.builderSnapshotPath
|
|
|| source.builderSnapshotSha256 !== archive.builderSnapshotSha256
|
|
|| source.capturedAt !== archive.capturedAt) {
|
|
throw new Error("CoA supplement manifest has unexpected capture metadata");
|
|
}
|
|
}
|
|
|
|
function expectedFilesFromManifest(manifest) {
|
|
validateManifestShape(manifest);
|
|
const expected = new Map();
|
|
for (const file of manifest.files) {
|
|
const filePath = assertSafeRelativePath(file?.path, "CoA supplement manifest file path");
|
|
if (expected.has(filePath)) throw new Error(`CoA supplement manifest repeats ${filePath}`);
|
|
if (!/^[0-9a-f]{40}$/i.test(file.gitBlobSha ?? "")) {
|
|
throw new Error(`CoA supplement manifest has an invalid Git blob SHA for ${filePath}`);
|
|
}
|
|
if (!/^[0-9a-f]{64}$/i.test(file.sha256 ?? "")) {
|
|
throw new Error(`CoA supplement manifest has an invalid SHA-256 for ${filePath}`);
|
|
}
|
|
if (!Number.isSafeInteger(file.size) || file.size < 0) {
|
|
throw new Error(`CoA supplement manifest has an invalid size for ${filePath}`);
|
|
}
|
|
expected.set(filePath, file);
|
|
}
|
|
for (const filePath of requiredPaths) {
|
|
if (!expected.has(filePath)) throw new Error(`CoA supplement manifest is missing ${filePath}`);
|
|
}
|
|
const classPaths = [...expected.keys()].filter(isClassArchivePath);
|
|
if (classPaths.length !== expectedClassCount || expected.size !== requiredPaths.length + expectedClassCount) {
|
|
throw new Error(`CoA supplement manifest must contain ${expectedClassCount} class files and ${requiredPaths.length} required files`);
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
async function responseBytes(fetchImpl, url, accept) {
|
|
let response;
|
|
try {
|
|
response = await fetchImpl(url, { headers: { Accept: accept } });
|
|
} catch (error) {
|
|
throw new Error(`CoA supplemental source unavailable at ${url}: ${error.message}`, { cause: error });
|
|
}
|
|
if (!response || response.ok !== true) {
|
|
const status = response?.status === undefined ? "no response" : `HTTP ${response.status}`;
|
|
throw new Error(`CoA supplemental source unavailable at ${url} (${status})`);
|
|
}
|
|
try {
|
|
if (typeof response.arrayBuffer === "function") return Buffer.from(await response.arrayBuffer());
|
|
if (typeof response.text === "function") return Buffer.from(await response.text(), "utf8");
|
|
} catch (error) {
|
|
throw new Error(`CoA supplemental source read failed at ${url}: ${error.message}`, { cause: error });
|
|
}
|
|
throw new Error(`CoA supplemental source response has no readable body at ${url}`);
|
|
}
|
|
|
|
async function fetchTree(fetchImpl) {
|
|
const { apiUrl } = archiveUrls(archive.fetchManifestPath);
|
|
const bytes = await responseBytes(fetchImpl, apiUrl, "application/vnd.github+json");
|
|
const treeDocument = parseJson(bytes, "GitHub archive tree");
|
|
if (treeDocument.truncated === true) {
|
|
throw new Error("CoA archive tree was truncated; refusing an incomplete supplemental cache");
|
|
}
|
|
if (!Array.isArray(treeDocument.tree)) throw new Error("CoA archive tree response does not contain a tree array");
|
|
return new Map(treeDocument.tree.map((entry) => [entry.path, entry]));
|
|
}
|
|
|
|
function expectedFile(tree, filePath) {
|
|
const safePath = assertSafeRelativePath(filePath);
|
|
const entry = tree.get(safePath);
|
|
if (!isRegularBlob(entry)) throw new Error(`Pinned CoA archive tree is missing a regular file at ${safePath}`);
|
|
if (!Number.isSafeInteger(entry.size) || entry.size < 0) {
|
|
throw new Error(`Pinned CoA archive tree has no valid file size for ${safePath}`);
|
|
}
|
|
return { path: safePath, gitBlobSha: entry.sha.toLowerCase(), size: entry.size };
|
|
}
|
|
|
|
async function readManifest(archiveRoot) {
|
|
try {
|
|
return parseJson(await fs.readFile(path.join(archiveRoot, manifestName)), manifestName);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readFileUnder(root, relativePath) {
|
|
const safePath = assertSafeRelativePath(relativePath);
|
|
const absoluteRoot = path.resolve(root);
|
|
const absolutePath = path.resolve(absoluteRoot, ...safePath.split("/"));
|
|
if (!absolutePath.startsWith(`${absoluteRoot}${path.sep}`)) {
|
|
throw new Error(`CoA archive path escapes its cache directory: ${relativePath}`);
|
|
}
|
|
return fs.readFile(absolutePath);
|
|
}
|
|
|
|
async function verifyFile(root, fileRecord) {
|
|
const bytes = await readFileUnder(root, fileRecord.path);
|
|
if (bytes.byteLength !== fileRecord.size) throw new Error(`CoA supplement size mismatch for ${fileRecord.path}`);
|
|
if (gitBlobSha(bytes) !== fileRecord.gitBlobSha.toLowerCase()) {
|
|
throw new Error(`CoA supplement Git blob checksum mismatch for ${fileRecord.path}`);
|
|
}
|
|
const actualSha256 = sha256(bytes);
|
|
if (actualSha256 !== fileRecord.sha256.toLowerCase()) {
|
|
throw new Error(`CoA supplement SHA-256 mismatch for ${fileRecord.path}`);
|
|
}
|
|
if (fileRecord.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
|
|
if (fileRecord.path === archive.builderSnapshotPath && actualSha256 !== archive.builderSnapshotSha256) {
|
|
throw new Error(`CoA builder snapshot does not match pinned SHA-256 ${archive.builderSnapshotSha256}`);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async function loadPublishedCache(archiveRoot) {
|
|
const manifest = await readManifest(archiveRoot);
|
|
if (manifest === null) return null;
|
|
const expected = expectedFilesFromManifest(manifest);
|
|
const generationRoot = path.join(archiveRoot, manifest.generation);
|
|
const contents = new Map();
|
|
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
|
|
parseCaptureManifest(contents.get(archive.fetchManifestPath));
|
|
|
|
const indexPath = "data/talents/coa/index.json";
|
|
const index = parseJson(contents.get(indexPath), indexPath);
|
|
const classPaths = classPathsFromIndex(index);
|
|
const recordedClassPaths = [...expected.keys()]
|
|
.filter(isClassArchivePath)
|
|
.sort();
|
|
if (classPaths.slice().sort().join("\n") !== recordedClassPaths.join("\n")) {
|
|
throw new Error("CoA supplement manifest class files do not match data/talents/coa/index.json");
|
|
}
|
|
|
|
const metadata = {
|
|
talents: parseJson(contents.get("data/talents/coa/_meta.json"), "data/talents/coa/_meta.json"),
|
|
provenance: parseJson(contents.get("raw/provenance.json"), "raw/provenance.json"),
|
|
clientSnapshot: parseJson(contents.get("raw/_snapshot.json"), "raw/_snapshot.json"),
|
|
spells: parseJson(contents.get("data/spells/_meta.json"), "data/spells/_meta.json"),
|
|
};
|
|
const descriptors = descriptorsFromIndex(index);
|
|
const rawClasses = classPaths.map((filePath) => parseJson(contents.get(filePath), filePath));
|
|
const hasTextReferences = rawClasses.some((definition) => definition.nodes?.some((node) => (
|
|
/^\$[0-9a-f]+$/i.test(node.description ?? "")
|
|
|| node.rankDescriptions?.some((rank) => /^\$[0-9a-f]+$/i.test(rank.description ?? ""))
|
|
)));
|
|
const classes = hasTextReferences
|
|
? resolveArchiveDescriptions(rawClasses, contents.get(archive.builderSnapshotPath).toString("utf8"))
|
|
: rawClasses;
|
|
const classFiles = classPaths.map((filePath, position) => ({ path: filePath, indexEntry: descriptors[position], data: classes[position] }));
|
|
return {
|
|
source: structuredClone(manifest.source),
|
|
classes,
|
|
classFiles,
|
|
metadata,
|
|
index,
|
|
manifest: structuredClone(manifest),
|
|
};
|
|
}
|
|
|
|
async function atomicWrite(filePath, bytes) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
const temporaryPath = `${filePath}.tmp-${randomUUID()}`;
|
|
try {
|
|
await fs.writeFile(temporaryPath, bytes, { flag: "wx" });
|
|
await fs.rename(temporaryPath, filePath);
|
|
} catch (error) {
|
|
await fs.rm(temporaryPath, { force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function discardInvalidFile(filePath) {
|
|
try {
|
|
await fs.rm(filePath, { force: true });
|
|
} catch (error) {
|
|
throw new Error(`Could not discard invalid cached CoA supplement ${filePath}: ${error.message}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
async function readAndVerifyStaged(stagingRoot, expected) {
|
|
let bytes;
|
|
try {
|
|
bytes = await readFileUnder(stagingRoot, expected.path);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
|
|
if (bytes.byteLength !== expected.size || gitBlobSha(bytes) !== expected.gitBlobSha) {
|
|
await discardInvalidFile(stagedPath);
|
|
return null;
|
|
}
|
|
const contentSha256 = sha256(bytes);
|
|
if (expected.path === archive.fetchManifestPath) {
|
|
try {
|
|
parseCaptureManifest(bytes);
|
|
} catch {
|
|
await discardInvalidFile(stagedPath);
|
|
return null;
|
|
}
|
|
}
|
|
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
|
|
await discardInvalidFile(stagedPath);
|
|
return null;
|
|
}
|
|
return { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
|
|
}
|
|
|
|
async function fetchAndPromoteFile(fetchImpl, stagingRoot, expected) {
|
|
const { rawUrl } = archiveUrls(expected.path);
|
|
const bytes = await responseBytes(fetchImpl, rawUrl, "application/octet-stream");
|
|
if (gitBlobSha(bytes) !== expected.gitBlobSha) {
|
|
throw new Error(`Downloaded CoA supplement Git blob checksum mismatch for ${expected.path}`);
|
|
}
|
|
const contentSha256 = sha256(bytes);
|
|
if (expected.path === archive.fetchManifestPath) parseCaptureManifest(bytes);
|
|
if (expected.path === archive.builderSnapshotPath && contentSha256 !== archive.builderSnapshotSha256) {
|
|
throw new Error(`Downloaded CoA builder snapshot SHA-256 mismatch for ${expected.path}`);
|
|
}
|
|
const record = { path: expected.path, gitBlobSha: expected.gitBlobSha, sha256: contentSha256, size: bytes.byteLength };
|
|
const stagedPath = path.join(stagingRoot, ...expected.path.split("/"));
|
|
await atomicWrite(stagedPath, bytes);
|
|
await verifyFile(stagingRoot, record);
|
|
return record;
|
|
}
|
|
|
|
async function stageFile(fetchImpl, stagingRoot, expected) {
|
|
const cached = await readAndVerifyStaged(stagingRoot, expected);
|
|
if (cached) return { record: cached, downloaded: false };
|
|
return { record: await fetchAndPromoteFile(fetchImpl, stagingRoot, expected), downloaded: true };
|
|
}
|
|
|
|
async function validateGeneration(generationRoot, manifest) {
|
|
const expected = expectedFilesFromManifest(manifest);
|
|
const contents = new Map();
|
|
for (const [filePath, record] of expected) contents.set(filePath, await verifyFile(generationRoot, record));
|
|
const index = parseJson(contents.get("data/talents/coa/index.json"), "data/talents/coa/index.json");
|
|
const indexedClassPaths = classPathsFromIndex(index).sort();
|
|
const recordedClassPaths = [...expected.keys()]
|
|
.filter(isClassArchivePath)
|
|
.sort();
|
|
if (indexedClassPaths.join("\n") !== recordedClassPaths.join("\n")) {
|
|
throw new Error("CoA supplement generation class files do not match the pinned index");
|
|
}
|
|
parseCaptureManifest(contents.get(archive.fetchManifestPath));
|
|
for (const filePath of [
|
|
"data/talents/coa/_meta.json",
|
|
"raw/provenance.json",
|
|
"raw/_snapshot.json",
|
|
"data/spells/_meta.json",
|
|
...indexedClassPaths,
|
|
]) {
|
|
parseJson(contents.get(filePath), filePath);
|
|
}
|
|
}
|
|
|
|
async function writeManifest(archiveRoot, manifest) {
|
|
await atomicWrite(
|
|
path.join(archiveRoot, manifestName),
|
|
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"),
|
|
);
|
|
}
|
|
|
|
function cacheSummary(manifest, cacheDirectory, reused, downloadedFiles = []) {
|
|
return {
|
|
source: structuredClone(manifest.source),
|
|
cacheDirectory,
|
|
revision: archive.revision,
|
|
capturedAt: archive.capturedAt,
|
|
fileCount: manifest.files.length,
|
|
classCount: expectedClassCount,
|
|
reused,
|
|
downloadedFiles,
|
|
};
|
|
}
|
|
|
|
async function readSupplements({ cacheRoot } = {}) {
|
|
const archiveRoot = archiveDirectory(cacheRoot);
|
|
try {
|
|
await fs.access(archiveRoot);
|
|
} catch (error) {
|
|
if (error.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
try {
|
|
const contents = await loadPublishedCache(archiveRoot);
|
|
if (contents === null) throw new Error("published manifest is missing");
|
|
return contents;
|
|
} catch (error) {
|
|
throw new Error(`CoA supplement cache at ${archiveRoot} is corrupt or incomplete: ${error.message}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
async function downloadSupplements({ cacheRoot, fetchImpl = globalThis.fetch, refresh = false } = {}) {
|
|
if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be a fetch-compatible function");
|
|
const archiveRoot = archiveDirectory(cacheRoot);
|
|
if (!refresh) {
|
|
try {
|
|
const cached = await loadPublishedCache(archiveRoot);
|
|
if (cached) return cacheSummary(cached.manifest, archiveRoot, true);
|
|
} catch {
|
|
// Keep the previous manifest and generation until a complete replacement is ready.
|
|
}
|
|
}
|
|
|
|
await fs.mkdir(archiveRoot, { recursive: true });
|
|
const tree = await fetchTree(fetchImpl);
|
|
const expected = new Map(requiredPaths.map((filePath) => [filePath, expectedFile(tree, filePath)]));
|
|
const stagingRoot = path.join(archiveRoot, "staging");
|
|
const stagedRecords = new Map();
|
|
const downloadedFiles = [];
|
|
|
|
for (const filePath of requiredPaths) {
|
|
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
|
|
stagedRecords.set(filePath, staged.record);
|
|
if (staged.downloaded) downloadedFiles.push(filePath);
|
|
}
|
|
|
|
const indexPath = "data/talents/coa/index.json";
|
|
const index = parseJson(await readFileUnder(stagingRoot, indexPath), indexPath);
|
|
const classPaths = classPathsFromIndex(index);
|
|
for (const filePath of classPaths) expected.set(filePath, expectedFile(tree, filePath));
|
|
for (const filePath of classPaths) {
|
|
const staged = await stageFile(fetchImpl, stagingRoot, expected.get(filePath));
|
|
stagedRecords.set(filePath, staged.record);
|
|
if (staged.downloaded) downloadedFiles.push(filePath);
|
|
}
|
|
|
|
const files = [...stagedRecords.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
if (files.length !== requiredPaths.length + expectedClassCount) {
|
|
throw new Error(`CoA supplemental cache expected ${requiredPaths.length + expectedClassCount} files but staged ${files.length}`);
|
|
}
|
|
parseCaptureManifest(await readFileUnder(stagingRoot, archive.fetchManifestPath));
|
|
const manifest = {
|
|
schemaVersion: cacheSchemaVersion,
|
|
source: sourceRecord(),
|
|
generation: `generation-${randomUUID()}`,
|
|
files,
|
|
};
|
|
// Validate every staged byte before moving the resumable cache into an immutable generation.
|
|
await validateGeneration(stagingRoot, manifest);
|
|
const generationRoot = path.join(archiveRoot, manifest.generation);
|
|
await fs.rename(stagingRoot, generationRoot);
|
|
await writeManifest(archiveRoot, manifest);
|
|
return cacheSummary(manifest, archiveRoot, false, downloadedFiles);
|
|
}
|
|
|
|
return Object.freeze({ readSupplements, downloadSupplements });
|
|
}
|
|
|
|
const defaultApi = createSupplementCacheApi();
|
|
|
|
export const readSupplements = defaultApi.readSupplements;
|
|
export const downloadSupplements = defaultApi.downloadSupplements;
|
|
|
|
export function getDefaultSupplementCacheRoot(projectRoot = defaultProjectRoot) {
|
|
return path.join(path.resolve(projectRoot), "data", "coa-reference", "cache");
|
|
}
|