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; } 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 { 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, ): Promise { 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 { 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 { 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 { 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; } }); }