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

330 lines
11 KiB
TypeScript

import { runCourseRegistryPublishExclusive } from '@/lib/course-framework/publish-state';
import { publishCourse } from '@/lib/course-manifest-repo';
import { courseManifestRepo } from '@/lib/course-manifest-repo/store';
import type { CourseManifestRecord, CourseManifestRepo } from '@/lib/course-manifest-repo/types';
import { inspectPublishableCoursewareBundle, publishCourseware } from '@/lib/courseware-repo';
import {
COURSEWARE_BUNDLES_DIR,
createFileBundleByteStore,
type BundleByteStore,
} from '@/lib/courseware-repo/bundle-store';
import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
import {
CoursePublishTransportError,
type RemoteCoursePublishMetadata,
} from '@/lib/server/course-publish-contract';
export interface RemoteCoursePublishArchive {
index: number;
zipBytes: Uint8Array;
}
export interface CoursePublishTransactionRepos {
coursewares: CoursewareRepo;
bundles: BundleByteStore;
manifests: CourseManifestRepo;
}
export interface CommitRemoteCoursePublishOptions {
metadata: RemoteCoursePublishMetadata;
archives: RemoteCoursePublishArchive[];
token: string | null | undefined;
/** Canonical learner-visible origin, never the internal request Host. */
publicBaseUrl: string;
repos?: Partial<CoursePublishTransactionRepos>;
}
export interface CommitRemoteCoursePublishResult {
record: CourseManifestRecord;
idempotent: boolean;
}
interface IncomingIdentity {
index: number;
contentHash: string;
}
async function storedBundleMatchesPin(
moduleRecord: RemoteCoursePublishMetadata['modules'][number],
coursewareVersion: number,
contentHash: string,
bundles: BundleByteStore,
): Promise<boolean> {
try {
const zipBytes = await bundles.read(moduleRecord.coursewareId, coursewareVersion);
if (!zipBytes) return false;
const { documents, contentHash: computedHash } = await inspectPublishableCoursewareBundle(
zipBytes,
moduleRecord.coursewareId,
);
return (
documents.meta.coursewareId === moduleRecord.coursewareId &&
documents.meta.version === coursewareVersion &&
documents.meta.contentHash === contentHash &&
computedHash === contentHash
);
} catch {
// A missing or corrupt immutable object can never be acknowledged as a
// successful retry. The caller will stage a fresh server-owned version.
return false;
}
}
function reposFor(options: CommitRemoteCoursePublishOptions): CoursePublishTransactionRepos {
return {
coursewares: options.repos?.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR),
bundles: options.repos?.bundles ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR),
manifests: options.repos?.manifests ?? courseManifestRepo,
};
}
async function inspectIncomingIdentities(
metadata: RemoteCoursePublishMetadata,
archiveByIndex: Map<number, Uint8Array>,
): Promise<IncomingIdentity[]> {
const identities: IncomingIdentity[] = [];
for (const moduleRecord of metadata.modules) {
const zipBytes = archiveByIndex.get(moduleRecord.index);
if (!zipBytes) {
throw new CoursePublishTransportError(
'MODULE_ARCHIVE_MISSING',
`Module ${moduleRecord.index} ZIP is missing`,
'request',
400,
moduleRecord.index,
);
}
try {
const inspected = await inspectPublishableCoursewareBundle(
zipBytes,
moduleRecord.coursewareId,
);
identities.push({ index: moduleRecord.index, contentHash: inspected.contentHash });
} catch (error) {
throw new CoursePublishTransportError(
'MODULE_PREFLIGHT_FAILED',
`Module ${moduleRecord.index} frozen bundle failed preflight`,
'staging',
409,
moduleRecord.index,
error instanceof Error ? error.message : undefined,
);
}
}
return identities;
}
async function findIdempotentManifest(
metadata: RemoteCoursePublishMetadata,
identities: readonly IncomingIdentity[],
repos: CoursePublishTransactionRepos,
): Promise<CourseManifestRecord | null> {
const latest = await repos.manifests.getLatestRecord(metadata.courseId);
if (
!latest ||
latest.schemaVersion !== 2 ||
latest.title !== metadata.title ||
latest.summary !== metadata.summary ||
(latest.language ?? undefined) !== (metadata.language ?? undefined) ||
latest.modules.length !== metadata.modules.length
) {
return null;
}
for (const moduleRecord of metadata.modules) {
const identity = identities.find((entry) => entry.index === moduleRecord.index);
const pin = latest.modules.find((entry) => entry.index === moduleRecord.index);
if (
!identity ||
!pin ||
pin.title !== moduleRecord.title ||
pin.description !== moduleRecord.description ||
pin.coursewareId !== moduleRecord.coursewareId ||
pin.contentHash !== identity.contentHash ||
!pin.coursewareVersion
) {
return null;
}
const published = await repos.coursewares.getRecord(
moduleRecord.coursewareId,
pin.coursewareVersion,
);
if (
!published ||
published.status !== 'published' ||
!published.complete ||
published.contentHash !== identity.contentHash ||
published.sourceClassroomId !== moduleRecord.sourceClassroomId ||
published.courseId !== metadata.courseId ||
published.courseModuleIndex !== moduleRecord.index ||
!(await storedBundleMatchesPin(
moduleRecord,
pin.coursewareVersion,
identity.contentHash,
repos.bundles,
))
) {
return null;
}
}
return latest;
}
async function rollbackStaged(
staged: ReadonlyArray<{ coursewareId: string; version: number }>,
coursewares: CoursewareRepo,
): Promise<string[]> {
const errors: string[] = [];
for (const record of [...staged].reverse()) {
try {
await coursewares.setStatus(record.coursewareId, record.version, 'unpublished');
} catch (error) {
errors.push(
`${record.coursewareId} v${record.version}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
return errors;
}
/**
* Single-process course transaction. File repos use compensating statuses;
* database-backed repos can preserve this contract with one real transaction.
*/
export async function commitRemoteCoursePublish(
options: CommitRemoteCoursePublishOptions,
): Promise<CommitRemoteCoursePublishResult> {
const { metadata } = options;
const repos = reposFor(options);
const archiveByIndex = new Map(
options.archives.map((archive) => [archive.index, archive.zipBytes]),
);
if (archiveByIndex.size !== metadata.modules.length) {
throw new CoursePublishTransportError(
'MODULE_ARCHIVE_LAYOUT_MISMATCH',
'Module ZIP count does not match metadata',
'request',
400,
);
}
return runCourseRegistryPublishExclusive(metadata.courseId, async () => {
const incomingIdentities = await inspectIncomingIdentities(metadata, archiveByIndex);
const existing = await findIdempotentManifest(metadata, incomingIdentities, repos);
if (existing) return { record: existing, idempotent: true };
const staged: Array<{ coursewareId: string; version: number }> = [];
const pins: Array<{
index: number;
title: string;
description: string;
coursewareId: string;
coursewareVersion: number;
contentHash: string;
}> = [];
try {
for (const moduleRecord of metadata.modules) {
try {
const published = await publishCourseware({
zipBytes: archiveByIndex.get(moduleRecord.index)!,
coursewareId: moduleRecord.coursewareId,
courseId: metadata.courseId,
courseModuleIndex: moduleRecord.index,
sourceClassroomId: moduleRecord.sourceClassroomId,
baseUrl: options.publicBaseUrl,
status: 'unpublished',
repos: { records: repos.coursewares, bytes: repos.bundles },
});
staged.push({
coursewareId: published.record.coursewareId,
version: published.record.version,
});
pins.push({
index: moduleRecord.index,
title: moduleRecord.title,
description: moduleRecord.description,
coursewareId: published.record.coursewareId,
coursewareVersion: published.record.version,
contentHash: published.record.contentHash,
});
} catch (error) {
throw new CoursePublishTransportError(
'MODULE_STAGE_FAILED',
`Module ${moduleRecord.index} could not be staged`,
'staging',
409,
moduleRecord.index,
error instanceof Error ? error.message : undefined,
);
}
}
for (const stagedRecord of staged) {
try {
await repos.coursewares.setStatus(
stagedRecord.coursewareId,
stagedRecord.version,
'published',
);
} catch (error) {
throw new CoursePublishTransportError(
'MODULE_PROMOTION_FAILED',
`Courseware ${stagedRecord.coursewareId} v${stagedRecord.version} could not be promoted`,
'commit',
500,
pins.find((pin) => pin.coursewareId === stagedRecord.coursewareId)?.index,
error instanceof Error ? error.message : undefined,
);
}
}
let manifestResult;
try {
manifestResult = await publishCourse({
courseId: metadata.courseId,
token: options.token,
title: metadata.title,
summary: metadata.summary,
language: metadata.language,
modules: pins,
repos: { coursewares: repos.coursewares, manifests: repos.manifests },
});
} catch (error) {
throw new CoursePublishTransportError(
'MANIFEST_COMMIT_FAILED',
'Course manifest could not be committed',
'commit',
500,
undefined,
error instanceof Error ? error.message : undefined,
);
}
if (!manifestResult.ok) {
throw new CoursePublishTransportError(
manifestResult.errorCode,
manifestResult.error,
'commit',
manifestResult.errorCode === 'UNAUTHORIZED' ? 401 : 409,
);
}
return { record: manifestResult.record, idempotent: false };
} catch (error) {
const rollbackErrors = await rollbackStaged(staged, repos.coursewares);
if (rollbackErrors.length > 0) {
throw new CoursePublishTransportError(
'ROLLBACK_FAILED',
'Course publish failed and one or more staged modules could not be hidden',
'rollback',
500,
error instanceof CoursePublishTransportError ? error.moduleIndex : undefined,
`${error instanceof Error ? error.message : String(error)}; ${rollbackErrors.join('; ')}`,
);
}
throw error;
}
});
}