// Legacy classroom migration — re-package existing server-side classrooms // (`data/classrooms/*.json`, stage + scenes) into published frozen bundles. // // The packager is byte-agnostic: media/narration bytes arrive through injected // resolvers. Deployments with server persistence resolve them from the asset // registry; otherwise (bytes only in learner browsers) the migration reports // the classroom as "re-publish from the ops workbench" instead of publishing a // broken bundle. The publish gate (`requireComplete`) is always on here. import { packageCourseware, type PackageResult } from '@/lib/bundle/packager'; import { publishCourseware } from '@/lib/courseware-repo'; import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store'; import { COURSEWARE_BUNDLES_DIR, createFileBundleByteStore, } from '@/lib/courseware-repo/bundle-store'; import { CLASSROOMS_DIR, type PersistedClassroomData } from '@/lib/server/classroom-storage'; import { promises as fs } from 'fs'; import path from 'path'; import { createLogger } from '@/lib/logger'; import type { CoursewareRepo } from '@/lib/courseware-repo/types'; import type { BundleByteStore } from '@/lib/courseware-repo/bundle-store'; import type { MediaFileRecord } from '@/lib/utils/database'; const log = createLogger('MigrateCoursewares'); export interface ClassroomByteResolvers { /** Resolve narration bytes by audioId (server asset registry, …). */ resolveAudioBytes?: (audioId: string) => Promise; /** Resolve media bytes by record shape (elementId + mimeType). */ resolveMediaBytes?: (record: MediaFileRecord) => Promise; } export interface MigrateClassroomOptions { /** Override the published courseware id (defaults to the classroom id). */ coursewareId?: string; /** Explicit version (defaults to the repo's next monotonic version). */ version?: number; baseUrl: string; resolvers?: ClassroomByteResolvers; repos?: { records?: CoursewareRepo; bytes?: BundleByteStore; }; } export type MigrateClassroomResult = | { status: 'published'; classroomId: string; coursewareId: string; version: number; record: Awaited>['record']; } | { status: 'incomplete'; classroomId: string; coursewareId: string; /** Human-readable missing-resource summary for the report. */ missing: string[]; completeness: PackageResult['completeness']; }; /** * Re-package one persisted classroom and publish it. Never publishes a broken * bundle: missing audio/media produce an `incomplete` result instead. */ export async function migrateClassroomToCourseware( classroom: PersistedClassroomData, options: MigrateClassroomOptions, ): Promise { const coursewareId = options.coursewareId ?? classroom.id; const { stage, scenes } = classroom; const resolvers = options.resolvers ?? {}; const packaged = await packageCourseware({ coursewareId, version: options.version, stage, scenes, agentConfigs: stage.generatedAgentConfigs ?? [], resolveAudioBytes: resolvers.resolveAudioBytes, resolveMediaBytes: resolvers.resolveMediaBytes, requireComplete: false, // report instead of throwing; the gate below decides }); if (!packaged.completeness.complete) { const missing = packaged.completeness.missing.map( (m) => `${m.kind}:${m.ref}${m.sceneId ? ` (scene ${m.sceneId})` : ''}`, ); log.warn(`Classroom ${classroom.id} incomplete — not published (${missing.length} gaps)`); return { status: 'incomplete', classroomId: classroom.id, coursewareId, missing, completeness: packaged.completeness, }; } const { record } = await publishCourseware({ zipBytes: new Uint8Array(await packaged.zip.arrayBuffer()), coursewareId, baseUrl: options.baseUrl, version: options.version, repos: { records: options.repos?.records ?? createFileCoursewareRepo(COURSEWARES_DIR), bytes: options.repos?.bytes ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR), }, }); log.info(`Published ${classroom.id} as ${coursewareId} v${record.version}`); return { status: 'published', classroomId: classroom.id, coursewareId, version: record.version, record, }; } /** Scan + migrate every classroom in the server classrooms dir. */ export async function migrateAllClassrooms(options: { baseUrl: string; resolvers?: ClassroomByteResolvers; classroomsDir?: string; repos?: MigrateClassroomOptions['repos']; }): Promise<{ published: MigrateClassroomResult[]; incomplete: MigrateClassroomResult[]; skipped: string[]; }> { const dir = options.classroomsDir ?? CLASSROOMS_DIR; let files: string[]; try { files = await fs.readdir(dir); } catch { return { published: [], incomplete: [], skipped: [] }; } const results: MigrateClassroomResult[] = []; const skipped: string[] = []; for (const file of files.filter((f) => f.endsWith('.json')).sort()) { const classroomId = path.basename(file, '.json'); let classroom: PersistedClassroomData; try { const content = await fs.readFile(path.join(dir, file), 'utf-8'); classroom = JSON.parse(content) as PersistedClassroomData; } catch { skipped.push(classroomId); continue; } results.push( await migrateClassroomToCourseware(classroom, { coursewareId: classroomId, baseUrl: options.baseUrl, resolvers: options.resolvers, repos: options.repos, }), ); } return { published: results.filter((r) => r.status === 'published'), incomplete: results.filter((r) => r.status === 'incomplete'), skipped, }; }