169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
// 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<Blob | null>;
|
|
/** Resolve media bytes by record shape (elementId + mimeType). */
|
|
resolveMediaBytes?: (record: MediaFileRecord) => Promise<Blob | null>;
|
|
}
|
|
|
|
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<ReturnType<typeof publishCourseware>>['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<MigrateClassroomResult> {
|
|
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,
|
|
};
|
|
}
|