195 lines
6.6 KiB
TypeScript
195 lines
6.6 KiB
TypeScript
import { buildCourseModuleOutputDigest, computeClassroomSourceRevisionHash } from './module-digest';
|
|
import type {
|
|
CourseModuleContinuityInputRef,
|
|
CourseModuleOutputDigest,
|
|
CourseModuleRecord,
|
|
CourseRecord,
|
|
} from './types';
|
|
import { readClassroom, type PersistedClassroomData } from '@/lib/server/classroom-storage';
|
|
import { readCourseRecordReconciled } from './store';
|
|
|
|
export interface ValidatedPublishModule {
|
|
module: CourseModuleRecord;
|
|
classroom: PersistedClassroomData;
|
|
outputDigest: CourseModuleOutputDigest;
|
|
sourceRevisionHash: string;
|
|
}
|
|
|
|
export interface CoursePublishSnapshot {
|
|
courseId: string;
|
|
fingerprint: string;
|
|
modules: ValidatedPublishModule[];
|
|
}
|
|
|
|
export class CoursePublishValidationError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
message: string,
|
|
public readonly moduleIndex?: number,
|
|
) {
|
|
super(message);
|
|
this.name = 'CoursePublishValidationError';
|
|
}
|
|
}
|
|
|
|
function sameContinuityRefs(
|
|
actual: readonly CourseModuleContinuityInputRef[] | undefined,
|
|
expected: readonly CourseModuleContinuityInputRef[],
|
|
): boolean {
|
|
if ((actual?.length ?? 0) !== expected.length) return false;
|
|
return expected.every((expectedRef, index) => {
|
|
const actualRef = actual?.[index];
|
|
return (
|
|
actualRef?.moduleIndex === expectedRef.moduleIndex &&
|
|
actualRef.classroomId === expectedRef.classroomId &&
|
|
actualRef.semanticHash === expectedRef.semanticHash
|
|
);
|
|
});
|
|
}
|
|
|
|
function publishFingerprint(
|
|
record: CourseRecord,
|
|
modules: readonly ValidatedPublishModule[],
|
|
): string {
|
|
return JSON.stringify({
|
|
framework: record.framework,
|
|
modules: modules.map(({ module, outputDigest, sourceRevisionHash }) => ({
|
|
index: module.index,
|
|
classroomId: module.classroomId,
|
|
semanticHash: outputDigest.semanticHash,
|
|
sourceRevisionHash,
|
|
continuityInputRefs: module.continuityInputRefs ?? [],
|
|
})),
|
|
});
|
|
}
|
|
|
|
export async function validateCoursePublishSnapshot(
|
|
record: CourseRecord,
|
|
): Promise<CoursePublishSnapshot> {
|
|
if (!record.framework || record.framework.modules.length === 0) {
|
|
throw new CoursePublishValidationError(
|
|
'FRAMEWORK_MISSING',
|
|
'Course framework is missing or empty',
|
|
);
|
|
}
|
|
if (record.modules.length !== record.framework.modules.length) {
|
|
throw new CoursePublishValidationError(
|
|
'MODULE_LAYOUT_MISMATCH',
|
|
'Course runtime modules no longer match the approved framework',
|
|
);
|
|
}
|
|
|
|
const runtimeModules = [...record.modules].sort((a, b) => a.index - b.index);
|
|
const frameworkModules = [...record.framework.modules].sort((a, b) => a.index - b.index);
|
|
const validated: ValidatedPublishModule[] = [];
|
|
|
|
for (let position = 0; position < runtimeModules.length; position += 1) {
|
|
const moduleRecord = runtimeModules[position];
|
|
const spec = frameworkModules[position];
|
|
if (moduleRecord.index !== position + 1 || spec.index !== moduleRecord.index) {
|
|
throw new CoursePublishValidationError(
|
|
'MODULE_LAYOUT_MISMATCH',
|
|
`Module ${moduleRecord.index} is out of framework order`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
if (moduleRecord.status !== 'succeeded' || !moduleRecord.classroomId) {
|
|
throw new CoursePublishValidationError(
|
|
'MODULE_NOT_GENERATED',
|
|
`Module ${moduleRecord.index} has no successful classroom`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
const classroom = await readClassroom(moduleRecord.classroomId).catch(() => null);
|
|
if (!classroom) {
|
|
throw new CoursePublishValidationError(
|
|
'MODULE_CLASSROOM_MISSING',
|
|
`Module ${moduleRecord.index} classroom ${moduleRecord.classroomId} is unavailable`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
const outputDigest = buildCourseModuleOutputDigest(classroom);
|
|
if (!moduleRecord.outputDigest) {
|
|
throw new CoursePublishValidationError(
|
|
'MODULE_OUTPUT_UNVERIFIED',
|
|
`Module ${moduleRecord.index} has no persisted output digest`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
const sourceRevisionHash = computeClassroomSourceRevisionHash(classroom);
|
|
if (!moduleRecord.outputDigest.sourceRevisionHash) {
|
|
throw new CoursePublishValidationError(
|
|
'MODULE_OUTPUT_UNVERIFIED',
|
|
`Module ${moduleRecord.index} predates exact source revision tracking; regenerate it before publishing`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
if (
|
|
moduleRecord.outputDigest.semanticHash !== outputDigest.semanticHash ||
|
|
moduleRecord.outputDigest.sourceRevisionHash !== sourceRevisionHash
|
|
) {
|
|
throw new CoursePublishValidationError(
|
|
'MODULE_OUTPUT_CHANGED',
|
|
`Module ${moduleRecord.index} classroom changed after generation; regenerate it before publishing`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
const hasInteractiveHtml = classroom.scenes.some(
|
|
(scene) =>
|
|
scene.content.type === 'interactive' &&
|
|
typeof scene.content.html === 'string' &&
|
|
scene.content.html.trim().length > 0,
|
|
);
|
|
if (!hasInteractiveHtml) {
|
|
throw new CoursePublishValidationError(
|
|
'INTERACTIVE_HTML_MISSING',
|
|
`Module ${moduleRecord.index} has no generated interactive HTML scene`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
|
|
const expectedRefs = validated.map(({ module, outputDigest: priorDigest }) => ({
|
|
moduleIndex: module.index,
|
|
classroomId: priorDigest.classroomId,
|
|
semanticHash: priorDigest.semanticHash,
|
|
}));
|
|
if (!sameContinuityRefs(moduleRecord.continuityInputRefs, expectedRefs)) {
|
|
throw new CoursePublishValidationError(
|
|
moduleRecord.continuityInputRefs === undefined
|
|
? 'CONTINUITY_UNVERIFIED'
|
|
: 'CONTINUITY_STALE',
|
|
`Module ${moduleRecord.index} was not generated from the current preceding module outputs`,
|
|
moduleRecord.index,
|
|
);
|
|
}
|
|
|
|
validated.push({ module: moduleRecord, classroom, outputDigest, sourceRevisionHash });
|
|
}
|
|
|
|
return {
|
|
courseId: record.id,
|
|
fingerprint: publishFingerprint(record, validated),
|
|
modules: validated,
|
|
};
|
|
}
|
|
|
|
export async function revalidateCoursePublishSnapshot(
|
|
snapshot: CoursePublishSnapshot,
|
|
): Promise<CoursePublishSnapshot> {
|
|
const current = await readCourseRecordReconciled(snapshot.courseId);
|
|
if (!current) {
|
|
throw new CoursePublishValidationError(
|
|
'COURSE_CHANGED_DURING_PUBLISH',
|
|
`Course ${snapshot.courseId} disappeared during publish`,
|
|
);
|
|
}
|
|
const revalidated = await validateCoursePublishSnapshot(current);
|
|
if (revalidated.fingerprint !== snapshot.fingerprint) {
|
|
throw new CoursePublishValidationError(
|
|
'COURSE_CHANGED_DURING_PUBLISH',
|
|
`Course ${snapshot.courseId} changed while its modules were being frozen`,
|
|
);
|
|
}
|
|
return revalidated;
|
|
}
|