Files
openmaic/OpenMAIC/lib/course-manifest-repo/store.ts
T

214 lines
7.7 KiB
TypeScript

// 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<string, Promise<void>>();
async function withManifestLock<T>(courseId: string, fn: () => Promise<T>): Promise<T> {
assertCourseManifestId(courseId);
const previous = manifestLocks.get(courseId) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((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<CourseManifestHistory & CourseManifestRecord>;
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<CourseManifestRecord[]> {
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<void> {
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<number> {
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<void> {
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<CourseManifestRecord, 'version'>,
): Promise<CourseManifestRecord> {
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<CourseManifestRecord | null> {
assertCourseManifestId(courseId);
assertCourseManifestVersion(version);
const versions = await readHistory(dir, courseId);
return versions.find((record) => record.version === version) ?? null;
},
async getLatestRecord(courseId: string): Promise<CourseManifestRecord | null> {
assertCourseManifestId(courseId);
const versions = await readHistory(dir, courseId);
return [...versions].sort((a, b) => b.version - a.version)[0] ?? null;
},
async listRecords(limit = 50): Promise<CourseManifestRecord[]> {
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<boolean> {
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();