389 lines
12 KiB
TypeScript
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();
|