// File-backed courseware repository (records only). // // Mirrors the classroom-storage pattern (`data/classrooms/*.json`): one JSON // file per courseware holding its version history, written atomically. Swap // this module for a Postgres-backed implementation behind the same // {@link CoursewareRepo} interface when the server tier grows. import { promises as fs } from 'fs'; import path from 'path'; import { type CoursewareRecord, type CoursewareRepo, type CoursewareStatus } from './types'; import { assertCoursewareId, assertCoursewareVersion, resolveDirectChildPath } from './identity'; import { writeJsonFileAtomic } from '@/lib/server/classroom-storage'; export const COURSEWARES_DIR = process.env.COURSEWARE_DATA_DIR ?? path.join(process.env.LEARNING_DATA_DIR ?? path.join(process.cwd(), 'data'), 'coursewares'); function recordsPath(dir: string, coursewareId: string): string { assertCoursewareId(coursewareId); return resolveDirectChildPath(dir, `${coursewareId}.json`); } export function createFileCoursewareRepo(dir: string = COURSEWARES_DIR): CoursewareRepo { const recordLocks = new Map>(); const withRecordLock = async (coursewareId: string, fn: () => Promise): Promise => { assertCoursewareId(coursewareId); const previous = recordLocks.get(coursewareId) ?? Promise.resolve(); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); recordLocks.set(coursewareId, current); try { await previous; return await fn(); } finally { release(); if (recordLocks.get(coursewareId) === current) recordLocks.delete(coursewareId); } }; const readHistory = async (coursewareId: string): Promise => { try { const content = await fs.readFile(recordsPath(dir, coursewareId), 'utf-8'); const parsed = JSON.parse(content) as { versions: CoursewareRecord[] }; return Array.isArray(parsed.versions) ? parsed.versions : []; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } }; const writeHistory = async (coursewareId: string, versions: CoursewareRecord[]) => { const filePath = recordsPath(dir, coursewareId); await fs.mkdir(dir, { recursive: true }); await writeJsonFileAtomic(filePath, { coursewareId, versions }); }; return { async nextVersion(coursewareId) { assertCoursewareId(coursewareId); const versions = await readHistory(coursewareId); const max = versions.reduce((m, r) => Math.max(m, r.version), 0); return max + 1; }, async saveRecord(record) { assertCoursewareId(record.coursewareId); assertCoursewareVersion(record.version); await withRecordLock(record.coursewareId, async () => { const versions = await readHistory(record.coursewareId); const existing = versions.find((item) => item.version === record.version); if (existing) { if (JSON.stringify(existing) === JSON.stringify(record)) return; throw new Error( `Courseware ${record.coursewareId} v${record.version} already exists and is immutable`, ); } await writeHistory(record.coursewareId, [...versions, record]); }); }, async setStatus(coursewareId, version, status: CoursewareStatus) { assertCoursewareId(coursewareId); assertCoursewareVersion(version); await withRecordLock(coursewareId, async () => { const versions = await readHistory(coursewareId); const record = versions.find((item) => item.version === version); if (!record) throw new Error(`Courseware ${coursewareId} v${version} not found`); if (record.status === status) return; record.status = status; await writeHistory(coursewareId, versions); }); }, async getRecord(coursewareId, version) { assertCoursewareId(coursewareId); assertCoursewareVersion(version); const versions = await readHistory(coursewareId); return versions.find((r) => r.version === version) ?? null; }, async getLatestRecord(coursewareId, options) { assertCoursewareId(coursewareId); const versions = await readHistory(coursewareId); const filtered = options?.status ? versions.filter((r) => r.status === options.status) : versions; const sorted = [...filtered].sort((a, b) => b.version - a.version); return sorted[0] ?? null; }, async listRecords(options) { const dirEntries = await fs.readdir(dir).catch(() => [] as string[]); const files = dirEntries.filter((name) => name.endsWith('.json')); const records: CoursewareRecord[] = []; for (const file of files) { try { const coursewareId = file.slice(0, -'.json'.length); assertCoursewareId(coursewareId); const content = await fs.readFile(recordsPath(dir, coursewareId), 'utf-8'); const parsed = JSON.parse(content) as { versions?: CoursewareRecord[] }; records.push(...(parsed.versions ?? [])); } catch { // A corrupt record file must not take the whole listing down. continue; } } const sorted = records.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1)); return options?.status ? sorted.filter((r) => r.status === options.status) : sorted; }, async listLatest(options) { const all = await this.listRecords(options); const byCourseware = new Map(); for (const record of all) { const existing = byCourseware.get(record.coursewareId); if (!existing || record.version > existing.version) byCourseware.set(record.coursewareId, record); } return [...byCourseware.values()].sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1)); }, async hasPublishedSource(classroomId) { assertCoursewareId(classroomId); const dirEntries = await fs.readdir(dir).catch(() => [] as string[]); const files = dirEntries.filter((name) => name.endsWith('.json')); for (const file of files) { // Unlike the public catalog listing, this authorization lookup must // fail closed: skipping a corrupt file could expose its mutable source. const coursewareId = file.slice(0, -'.json'.length); assertCoursewareId(coursewareId); const content = await fs.readFile(recordsPath(dir, coursewareId), 'utf-8'); const parsed = JSON.parse(content) as { versions?: CoursewareRecord[] }; if (!Array.isArray(parsed.versions)) { throw new Error(`Invalid courseware registry history: ${file}`); } if ( parsed.versions.some( (record) => record.status === 'published' && (record.sourceClassroomId === classroomId || record.coursewareId === classroomId), ) ) { return true; } } return false; }, }; }