173 lines
6.9 KiB
TypeScript
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;
|
|
},
|
|
};
|
|
}
|