164 lines
4.9 KiB
TypeScript
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);
|
|
}
|
|
}
|