Files
openmaic/OpenMAIC/lib/course-framework/publish-state.ts
2026-08-16 14:58:47 +08:00

164 lines
4.9 KiB
TypeScript

type CourseSourceActivity = 'generation' | 'publishing';
interface ActiveSourceActivity {
kind: CourseSourceActivity;
token: symbol;
}
// Operations-side source mutation gate. Generation and publication must share
// one synchronous check+claim boundary or their separate `is...` prechecks
// have a TOCTOU window.
const sourceActivities = new Map<string, ActiveSourceActivity>();
// Registry publication runs in the server process. It is deliberately a
// separate namespace: a remote ops publish holds the source gate while the
// server handling that request holds this registry gate.
const registryPublishingCourses = new Set<string>();
type ClassroomSourceActivity = 'publishing' | 'deleting';
interface ActiveClassroomSourceActivity {
kind: ClassroomSourceActivity;
token: symbol;
}
// The ops publication transaction reads mutable classrooms and later records
// a receipt for the immutable server commit. Source deletion must share this
// synchronous claim boundary or it can race the final revalidation/commit.
const classroomSourceActivities = new Map<string, ActiveClassroomSourceActivity>();
export class CourseMutationInProgressError extends Error {
constructor(
public readonly courseId: string,
public readonly requested: CourseSourceActivity,
public readonly active: CourseSourceActivity,
) {
super(`Course ${courseId} cannot start ${requested}; ${active} is already active`);
this.name = 'CourseMutationInProgressError';
}
}
export class CoursePublishInProgressError extends Error {
constructor(courseId: string, detail = 'already has an active publish') {
super(`Course ${courseId} ${detail}`);
this.name = 'CoursePublishInProgressError';
}
}
export class ClassroomSourceMutationInProgressError extends Error {
constructor(
public readonly classroomId: string,
public readonly requested: ClassroomSourceActivity,
public readonly active: ClassroomSourceActivity,
) {
super(`Classroom source ${classroomId} cannot start ${requested}; ${active} is already active`);
this.name = 'ClassroomSourceMutationInProgressError';
}
}
function acquireClassroomSourceActivities(
classroomIds: readonly string[],
kind: ClassroomSourceActivity,
): () => void {
const uniqueIds = [...new Set(classroomIds)];
for (const classroomId of uniqueIds) {
const active = classroomSourceActivities.get(classroomId);
if (active) {
throw new ClassroomSourceMutationInProgressError(classroomId, kind, active.kind);
}
}
const token = Symbol(kind);
for (const classroomId of uniqueIds) {
classroomSourceActivities.set(classroomId, { kind, token });
}
return () => {
for (const classroomId of uniqueIds) {
if (classroomSourceActivities.get(classroomId)?.token === token) {
classroomSourceActivities.delete(classroomId);
}
}
};
}
export async function runClassroomSourcesPublishExclusive<T>(
classroomIds: readonly string[],
publish: () => Promise<T>,
): Promise<T> {
const release = acquireClassroomSourceActivities(classroomIds, 'publishing');
try {
return await publish();
} finally {
release();
}
}
export async function runClassroomSourceDeleteExclusive<T>(
classroomId: string,
remove: () => Promise<T>,
): Promise<T> {
const release = acquireClassroomSourceActivities([classroomId], 'deleting');
try {
return await remove();
} finally {
release();
}
}
export function isCoursePublishing(courseId: string): boolean {
return sourceActivities.get(courseId)?.kind === 'publishing';
}
export function acquireCourseGenerationActivity(courseId: string): () => void {
const active = sourceActivities.get(courseId);
if (active) {
throw new CourseMutationInProgressError(courseId, 'generation', active.kind);
}
const token = Symbol(courseId);
sourceActivities.set(courseId, { kind: 'generation', token });
return () => {
if (sourceActivities.get(courseId)?.token === token) {
sourceActivities.delete(courseId);
}
};
}
export async function runCoursePublishExclusive<T>(
courseId: string,
publish: () => Promise<T>,
): Promise<T> {
const active = sourceActivities.get(courseId);
if (active) {
throw new CoursePublishInProgressError(
courseId,
`cannot publish while ${active.kind} is active`,
);
}
const token = Symbol(courseId);
sourceActivities.set(courseId, { kind: 'publishing', token });
try {
return await publish();
} finally {
if (sourceActivities.get(courseId)?.token === token) {
sourceActivities.delete(courseId);
}
}
}
/** Server-side registry/manifest gate, separate from the ops source gate. */
export async function runCourseRegistryPublishExclusive<T>(
courseId: string,
publish: () => Promise<T>,
): Promise<T> {
if (registryPublishingCourses.has(courseId)) {
throw new CoursePublishInProgressError(courseId);
}
registryPublishingCourses.add(courseId);
try {
return await publish();
} finally {
registryPublishingCourses.delete(courseId);
}
}