214 lines
7.7 KiB
TypeScript
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();
|