Files
openmaic/OpenMAIC/lib/courseware-repo/store.ts

173 lines
6.9 KiB
TypeScript

// 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<string, Promise<void>>();
const withRecordLock = async <T>(coursewareId: string, fn: () => Promise<T>): Promise<T> => {
assertCoursewareId(coursewareId);
const previous = recordLocks.get(coursewareId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((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<CoursewareRecord[]> => {
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<string, CoursewareRecord>();
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;
},
};
}