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(); // 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(); 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(); 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( classroomIds: readonly string[], publish: () => Promise, ): Promise { const release = acquireClassroomSourceActivities(classroomIds, 'publishing'); try { return await publish(); } finally { release(); } } export async function runClassroomSourceDeleteExclusive( classroomId: string, remove: () => Promise, ): Promise { 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( courseId: string, publish: () => Promise, ): Promise { 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( courseId: string, publish: () => Promise, ): Promise { if (registryPublishingCourses.has(courseId)) { throw new CoursePublishInProgressError(courseId); } registryPublishingCourses.add(courseId); try { return await publish(); } finally { registryPublishingCourses.delete(courseId); } }