330 lines
11 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
}
|