Files
classroomio/packages/db/src/scripts/backfill-exercise-course.ts
T
2026-05-19 04:32:32 +01:00

389 lines
12 KiB
TypeScript

import { and, eq, inArray, isNotNull, or, sql } from 'drizzle-orm';
import { exercise, lesson } from '../schema';
import { db } from '../drizzle';
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const backupDir = path.resolve(scriptDir, '../../backups');
const orderBatchSize = Number.parseInt(process.env.EXERCISE_BACKFILL_BATCH_SIZE ?? '200', 10) || 200;
/** Optional course scope: `pnpm db:exercise-backfill [courseId]` */
const scopedCourseId = process.argv[2]?.trim() || null;
type LessonRow = {
id: string;
courseId: string;
sectionId: string | null;
order: number | null;
createdAt: string | null;
};
type ExerciseRow = {
id: string;
lessonId: string | null;
courseId: string | null;
sectionId: string | null;
order: number | null;
createdAt: string | null;
};
type OrderUpdate = { id: string; order: number };
type FailedOrderBatch = {
table: 'lesson' | 'exercise';
batchIndex: number;
rowCount: number;
ids: string[];
error: string;
};
type OrderBackfillReport = {
startedAt: string;
completedAt: string;
courseId: string | null;
batchSize: number;
summary: {
lessonsAttempted: number;
lessonsUpdated: number;
lessonsFailed: number;
exercisesAttempted: number;
exercisesUpdated: number;
exercisesFailed: number;
};
failures: FailedOrderBatch[];
reportPath: string;
};
/**
* Migrates lesson-attached exercises from the V1 model to the V2 linear content model.
*
* **V1:** exercises nested under a lesson (`exercise.lesson_id`); lesson `order` was the
* only sequence that mattered for navigation.
*
* **V2:** lessons and exercises share one `order` column per course (and per section when
* `section_id` is set). The UI sorts both tables by that single sequence.
*
* Runs two phases in order:
* 1. {@link setExerciseCourseAndSectionId} — copy `course_id` / `section_id` from the parent lesson.
* 2. {@link assignLinearContentOrder} — renumber `lesson.order` and `exercise.order` in one shared sequence.
*
* @example
* // Single course (dry-run scope while validating):
* pnpm --filter @cio/db db:exercise-backfill 73432009-4c40-457d-9e90-6b48b18b0e23
*
* @example
* // All courses:
* pnpm --filter @cio/db db:exercise-backfill
*/
async function backfillExerciseCourse() {
try {
const courseSectionResult = await setExerciseCourseAndSectionId(scopedCourseId);
const orderResult = await assignLinearContentOrder(scopedCourseId);
console.log('Exercise course/section backfill completed', courseSectionResult);
console.log('Linear content order backfill completed', orderResult.summary);
console.log('Report written to', orderResult.reportPath);
if (orderResult.failures.length > 0) {
console.error(`${orderResult.failures.length} batch(es) failed — see report for ids and errors.`);
process.exit(1);
}
process.exit(0);
} catch (error) {
console.error('Failed to backfill exercise course data', error);
process.exit(1);
}
}
/**
* Assigns a single 1-based `order` sequence to lessons and their child exercises.
*
* **Grouping** — Items are processed independently per `(course_id, section_id)` bucket.
* Unsectioned content uses `section_id = null`. Section order is not changed here.
*
* **Lesson ordering** — Within each bucket, lessons are sorted by existing `order`
* (ascending), with `null` orders last; ties break on `created_at`.
*
* **Exercise ordering** — Exercises with a `lesson_id` are grouped under that lesson.
* Within a lesson, exercises sort by `order` then `created_at` (same comparator as lessons).
*
* **Linear sequence** — For each lesson in sorted order, emit one slot for the lesson,
* then one slot per child exercise (in exercise sort order). Assign `order = 1..n` across
* that combined list and persist both tables in batched `CASE` updates
* (`EXERCISE_BACKFILL_BATCH_SIZE`, default 200). Failed batches are logged and skipped;
* the run continues until all batches are attempted. See `packages/db/backups/exercise-backfill-report-*.json`.
*
* Example (one section bucket):
*
* ```
* Before (lesson order only): After (shared order):
* L1 order=1 L1 order=1
* └ Ea (nested) Ea order=2
* └ Eb Eb order=3
* L2 order=2 L2 order=4
* └ Ec Ec order=5
* ```
*
* Only exercises that still have `lesson_id` are included. Course-level exercises
* (`lesson_id IS NULL`) are left unchanged.
*
* @param courseId — When set, only lessons/exercises for this course are loaded and updated.
*/
async function assignLinearContentOrder(courseId: string | null) {
const lessonWhere = courseId ? eq(lesson.courseId, courseId) : undefined;
const allLessons = await db
.select({
id: lesson.id,
courseId: lesson.courseId,
sectionId: lesson.sectionId,
order: lesson.order,
createdAt: lesson.createdAt
})
.from(lesson)
.where(lessonWhere);
const allExercises = await db
.select({
id: exercise.id,
lessonId: exercise.lessonId,
courseId: exercise.courseId,
sectionId: exercise.sectionId,
order: exercise.order,
createdAt: exercise.createdAt
})
.from(exercise)
.leftJoin(lesson, eq(exercise.lessonId, lesson.id))
.where(
courseId
? and(or(eq(exercise.courseId, courseId), eq(lesson.courseId, courseId)), isNotNull(exercise.lessonId))
: isNotNull(exercise.lessonId)
);
const lessonsByGroup = new Map<string, LessonRow[]>();
for (const lessonRow of allLessons) {
const key = groupKey(lessonRow.courseId, lessonRow.sectionId);
const group = lessonsByGroup.get(key) ?? [];
group.push(lessonRow);
lessonsByGroup.set(key, group);
}
const exercisesByLesson = new Map<string, ExerciseRow[]>();
for (const exerciseRow of allExercises) {
if (!exerciseRow.lessonId) {
continue;
}
const lessonExercises = exercisesByLesson.get(exerciseRow.lessonId) ?? [];
lessonExercises.push(exerciseRow);
exercisesByLesson.set(exerciseRow.lessonId, lessonExercises);
}
for (const lessonExercises of exercisesByLesson.values()) {
sortExercises(lessonExercises);
}
const lessonOrderUpdates: Array<{ id: string; order: number }> = [];
const exerciseOrderUpdates: Array<{ id: string; order: number }> = [];
for (const [, groupLessons] of lessonsByGroup) {
const sortedLessons = sortLessons(groupLessons);
let nextOrder = 1;
for (const lessonRow of sortedLessons) {
lessonOrderUpdates.push({ id: lessonRow.id, order: nextOrder });
nextOrder += 1;
const lessonExercises = exercisesByLesson.get(lessonRow.id) ?? [];
for (const exerciseRow of lessonExercises) {
exerciseOrderUpdates.push({ id: exerciseRow.id, order: nextOrder });
nextOrder += 1;
}
}
}
const startedAt = new Date().toISOString();
const failures: FailedOrderBatch[] = [];
const lessonsUpdated = await applyBatchedOrderUpdates('lesson', lessonOrderUpdates, failures);
const exercisesUpdated = await applyBatchedOrderUpdates('exercise', exerciseOrderUpdates, failures);
const report = buildOrderBackfillReport({
startedAt,
courseId,
lessonOrderUpdates,
exerciseOrderUpdates,
lessonsUpdated,
exercisesUpdated,
failures
});
mkdirSync(backupDir, { recursive: true });
writeFileSync(report.reportPath, JSON.stringify(report, null, 2));
return report;
}
/**
* Denormalizes `course_id` and `section_id` onto exercises from their parent lesson.
*
* Updates rows where `lesson_id` is set and either `course_id` or `section_id` is still null,
* so later queries can filter exercises by course without joining `lesson`.
*/
async function setExerciseCourseAndSectionId(courseId: string | null) {
if (courseId) {
return db.execute(sql`
UPDATE exercise AS e
SET course_id = l.course_id,
section_id = l.section_id
FROM lesson AS l
WHERE e.lesson_id = l.id
AND l.course_id = ${courseId}
AND (e.course_id IS NULL OR e.section_id IS NULL)
`);
}
return db.execute(sql`
UPDATE exercise AS e
SET course_id = l.course_id,
section_id = l.section_id
FROM lesson AS l
WHERE e.lesson_id = l.id
AND (e.course_id IS NULL OR e.section_id IS NULL)
`);
}
async function applyBatchedOrderUpdates(
table: 'lesson' | 'exercise',
updates: OrderUpdate[],
failures: FailedOrderBatch[]
) {
if (updates.length === 0) {
return 0;
}
let updatedCount = 0;
const totalBatches = Math.ceil(updates.length / orderBatchSize);
for (let batchIndex = 0; batchIndex < totalBatches; batchIndex += 1) {
const batchStart = batchIndex * orderBatchSize;
const batch = updates.slice(batchStart, batchStart + orderBatchSize);
const batchIds = batch.map((update) => update.id);
try {
if (table === 'lesson') {
await db
.update(lesson)
.set({
order: sql`case ${lesson.id} ${sql.join(
batch.map((update) => sql`when ${update.id} then ${update.order}`),
sql` `
)} else ${lesson.order} end`
})
.where(inArray(lesson.id, batchIds));
} else {
await db
.update(exercise)
.set({
order: sql`case ${exercise.id} ${sql.join(
batch.map((update) => sql`when ${update.id} then ${update.order}`),
sql` `
)} else ${exercise.order} end`
})
.where(inArray(exercise.id, batchIds));
}
updatedCount += batch.length;
console.log(`Updated ${table} batch ${batchIndex + 1}/${totalBatches} (${batch.length} rows)`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push({
table,
batchIndex,
rowCount: batch.length,
ids: batchIds,
error: message
});
console.error(`Failed ${table} batch ${batchIndex + 1}/${totalBatches} (${batch.length} rows):`, message);
}
}
return updatedCount;
}
function buildOrderBackfillReport(input: {
startedAt: string;
courseId: string | null;
lessonOrderUpdates: OrderUpdate[];
exerciseOrderUpdates: OrderUpdate[];
lessonsUpdated: number;
exercisesUpdated: number;
failures: FailedOrderBatch[];
}): OrderBackfillReport {
const stamp = input.startedAt.replace(/[:.]/g, '-');
const reportPath = path.join(backupDir, `exercise-backfill-report-${stamp}.json`);
const lessonsAttempted = input.lessonOrderUpdates.length;
const exercisesAttempted = input.exerciseOrderUpdates.length;
const lessonsFailed = input.failures
.filter((failure) => failure.table === 'lesson')
.reduce((count, failure) => count + failure.rowCount, 0);
const exercisesFailed = input.failures
.filter((failure) => failure.table === 'exercise')
.reduce((count, failure) => count + failure.rowCount, 0);
return {
startedAt: input.startedAt,
completedAt: new Date().toISOString(),
courseId: input.courseId,
batchSize: orderBatchSize,
summary: {
lessonsAttempted,
lessonsUpdated: input.lessonsUpdated,
lessonsFailed,
exercisesAttempted,
exercisesUpdated: input.exercisesUpdated,
exercisesFailed
},
failures: input.failures,
reportPath
};
}
function groupKey(courseId: string, sectionId: string | null) {
return `${courseId}-${sectionId ?? 'null'}`;
}
function compareByOrderThenCreatedAt<T extends { order: number | null; createdAt: string | null }>(left: T, right: T) {
if (left.order !== null && right.order !== null) {
return left.order - right.order;
}
if (left.order !== null) {
return -1;
}
if (right.order !== null) {
return 1;
}
return new Date(left.createdAt || '').getTime() - new Date(right.createdAt || '').getTime();
}
function sortLessons(lessons: LessonRow[]) {
return [...lessons].sort(compareByOrderThenCreatedAt);
}
function sortExercises(exercises: ExerciseRow[]) {
return [...exercises].sort(compareByOrderThenCreatedAt);
}
backfillExerciseCourse();