// Course manifest repo — file-backed implementation. // // One JSON history file per course in `data/course-manifests/` (env // COURSE_MANIFEST_DIR). Legacy files containing one bare record remain readable // and are upgraded to the history envelope on the next publish. import { promises as fs } from 'fs'; import path from 'path'; import type { CourseManifestRecord, CourseManifestRepo } from './types'; export const COURSE_MANIFEST_DIR = process.env.COURSE_MANIFEST_DIR ?? path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'course-manifests'); export function isValidCourseManifestId(courseId: unknown): courseId is string { return typeof courseId === 'string' && /^[a-zA-Z0-9_-]+$/.test(courseId); } function assertCourseManifestId(courseId: unknown): asserts courseId is string { if (!isValidCourseManifestId(courseId)) { throw new Error('Invalid course manifest id'); } } function isValidCourseManifestVersion(version: unknown): version is number { return typeof version === 'number' && Number.isSafeInteger(version) && version >= 1; } function assertCourseManifestVersion(version: unknown): asserts version is number { if (!isValidCourseManifestVersion(version)) { throw new Error('Invalid course manifest version'); } } function manifestFilePath(dir: string, courseId: string): string { assertCourseManifestId(courseId); const root = path.resolve(dir); const filePath = path.resolve(root, `${courseId}.json`); if (path.dirname(filePath) !== root) { throw new Error('Invalid course manifest storage path'); } return filePath; } async function writeJsonAtomic(filePath: string, data: unknown) { const dir = path.dirname(filePath); await fs.mkdir(dir, { recursive: true }); const tempFilePath = `${filePath}.${process.pid}.${Date.now()}.tmp`; await fs.writeFile(tempFilePath, JSON.stringify(data, null, 2), 'utf-8'); await fs.rename(tempFilePath, filePath); } interface CourseManifestHistory { courseId: string; versions: CourseManifestRecord[]; } const manifestLocks = new Map>(); async function withManifestLock(courseId: string, fn: () => Promise): Promise { assertCourseManifestId(courseId); const previous = manifestLocks.get(courseId) ?? Promise.resolve(); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); manifestLocks.set(courseId, current); try { await previous; return await fn(); } finally { release(); if (manifestLocks.get(courseId) === current) manifestLocks.delete(courseId); } } function parseHistory(value: unknown, courseId: string): CourseManifestRecord[] { if (!value || typeof value !== 'object') return []; const object = value as Partial; if (Array.isArray(object.versions)) { return object.versions.filter( (record): record is CourseManifestRecord => !!record && record.courseId === courseId && isValidCourseManifestVersion(record.version), ); } if (object.courseId === courseId && isValidCourseManifestVersion(object.version)) { return [object as CourseManifestRecord]; } return []; } async function readHistory(dir: string, courseId: string): Promise { try { const content = await fs.readFile(manifestFilePath(dir, courseId), 'utf-8'); return parseHistory(JSON.parse(content) as unknown, courseId); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } } async function writeHistory( dir: string, courseId: string, versions: CourseManifestRecord[], ): Promise { const history: CourseManifestHistory = { courseId, versions: [...versions].sort((a, b) => a.version - b.version), }; await writeJsonAtomic(manifestFilePath(dir, courseId), history); } export function createFileCourseManifestRepo( dir: string = COURSE_MANIFEST_DIR, ): CourseManifestRepo { return { async nextVersion(courseId: string): Promise { const versions = await readHistory(dir, courseId); const next = versions.reduce((highest, record) => Math.max(highest, record.version), 0) + 1; assertCourseManifestVersion(next); return next; }, async saveRecord(record: CourseManifestRecord): Promise { assertCourseManifestId(record.courseId); assertCourseManifestVersion(record.version); await withManifestLock(record.courseId, async () => { const versions = await readHistory(dir, record.courseId); const existing = versions.find((item) => item.version === record.version); if (existing) { if (JSON.stringify(existing) === JSON.stringify(record)) return; throw new Error( `Course manifest ${record.courseId} v${record.version} already exists and is immutable`, ); } await writeHistory(dir, record.courseId, [...versions, record]); }); }, async saveNextRecord( draft: Omit, ): Promise { assertCourseManifestId(draft.courseId); return withManifestLock(draft.courseId, async () => { const versions = await readHistory(dir, draft.courseId); const version = versions.reduce((highest, record) => Math.max(highest, record.version), 0) + 1; assertCourseManifestVersion(version); const record: CourseManifestRecord = { ...draft, version }; await writeHistory(dir, draft.courseId, [...versions, record]); return record; }); }, async getRecord(courseId: string, version: number): Promise { assertCourseManifestId(courseId); assertCourseManifestVersion(version); const versions = await readHistory(dir, courseId); return versions.find((record) => record.version === version) ?? null; }, async getLatestRecord(courseId: string): Promise { assertCourseManifestId(courseId); const versions = await readHistory(dir, courseId); return [...versions].sort((a, b) => b.version - a.version)[0] ?? null; }, async listRecords(limit = 50): Promise { const dirEntries = await fs.readdir(dir).catch(() => [] as string[]); const records: CourseManifestRecord[] = []; for (const file of dirEntries.filter((name) => name.endsWith('.json'))) { try { const courseId = path.basename(file, '.json'); if (!isValidCourseManifestId(courseId)) continue; const versions = await readHistory(dir, courseId); const latest = [...versions].sort((a, b) => b.version - a.version)[0]; if (latest) records.push(latest); } catch { continue; } } return records .sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1)) .slice(0, Math.max(1, limit)); }, async hasExactPin(courseId, pin): Promise { if ( !isValidCourseManifestId(courseId) || !/^[a-zA-Z0-9_-]+$/.test(pin.coursewareId) || !isValidCourseManifestVersion(pin.coursewareVersion) || !/^[0-9a-f]{64}$/.test(pin.contentHash) ) { return false; } const versions = await readHistory(dir, courseId); return versions.some( (record) => record.schemaVersion === 2 && record.modules.some( (moduleRecord) => moduleRecord.index === pin.index && moduleRecord.coursewareId === pin.coursewareId && moduleRecord.coursewareVersion === pin.coursewareVersion && moduleRecord.contentHash === pin.contentHash, ), ); }, } satisfies CourseManifestRepo; } export const courseManifestRepo = createFileCourseManifestRepo();