-
Notifications
You must be signed in to change notification settings - Fork 1
feat(timetable): add cleanup for orphaned cohorts #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { getLogger } from '@logtape/logtape'; | ||
| import { inArray } from 'drizzle-orm'; | ||
| import { db } from '#database'; | ||
| import { user } from '#database/schema/authentication'; | ||
| import { cohort, cohortTimetableMtm } from '#database/schema/timetable'; | ||
|
|
||
| const logger = getLogger(['chronos', 'timetable', 'cleanup']); | ||
|
|
||
| export type OrphanCleanupSummary = { | ||
| /** IDs of the deleted cohort rows. */ | ||
| deletedCohortIds: string[]; | ||
| /** Number of users whose cohortId was nullified. */ | ||
| affectedUserCount: number; | ||
| }; | ||
|
|
||
| /** | ||
| * Find and delete cohorts that are not linked to any timetable via the | ||
| * `cohort_timetable_mtm` table. For each orphan, any user referencing that | ||
| * cohort has their `cohortId` set to NULL before the cohort row is removed. | ||
| * | ||
| * The destructive part of the operation (user nullification + cohort deletion) | ||
| * runs inside a single database transaction. | ||
| */ | ||
| export async function cleanupOrphanedCohorts(): Promise<OrphanCleanupSummary> { | ||
| // Find all cohort IDs that are referenced in the MTM table | ||
| const linkedRows = await db | ||
| .selectDistinct({ cohortId: cohortTimetableMtm.cohortId }) | ||
| .from(cohortTimetableMtm); | ||
|
|
||
| const linkedSet = new Set(linkedRows.map((r) => r.cohortId)); | ||
|
|
||
| // Find all cohort IDs | ||
| const allCohortRows = await db.select({ id: cohort.id }).from(cohort); | ||
|
|
||
| // Cohorts whose ID does not appear in the MTM table are orphaned | ||
| const orphanedCohortIds = allCohortRows | ||
| .map((r) => r.id) | ||
| .filter((id) => !linkedSet.has(id)); | ||
|
|
||
| if (orphanedCohortIds.length === 0) { | ||
| logger.info('No orphaned cohorts found.'); | ||
| return { affectedUserCount: 0, deletedCohortIds: [] }; | ||
| } | ||
|
|
||
| logger.info('Found orphaned cohorts', { count: orphanedCohortIds.length }); | ||
|
|
||
| let affectedUserCount = 0; | ||
|
|
||
| await db.transaction(async (tx) => { | ||
| // Nullify cohortId on users referencing orphaned cohorts | ||
| const affectedUsers = await tx | ||
| .select({ id: user.id }) | ||
| .from(user) | ||
| .where(inArray(user.cohortId, orphanedCohortIds)); | ||
|
|
||
| if (affectedUsers.length > 0) { | ||
| const userIds = affectedUsers.map((u) => u.id); | ||
| await tx | ||
| .update(user) | ||
| .set({ cohortId: null }) | ||
| .where(inArray(user.id, userIds)); | ||
| affectedUserCount = userIds.length; | ||
| logger.info('Nullified cohortId for users', { count: userIds.length }); | ||
|
Comment on lines
+51
to
+63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Avoid stale-ID updates when nullifying user cohort assignments. Line 51–63 selects users, then updates by Proposed fix- const affectedUsers = await tx
- .select({ id: user.id })
- .from(user)
- .where(inArray(user.cohortId, orphanedCohortIds));
-
- if (affectedUsers.length > 0) {
- const userIds = affectedUsers.map((u) => u.id);
- await tx
- .update(user)
- .set({ cohortId: null })
- .where(inArray(user.id, userIds));
- affectedUserCount = userIds.length;
- logger.info('Nullified cohortId for users', { count: userIds.length });
- }
+ const updatedUsers = await tx
+ .update(user)
+ .set({ cohortId: null })
+ .where(inArray(user.cohortId, orphanedCohortIds))
+ .returning({ id: user.id });
+
+ affectedUserCount = updatedUsers.length;
+ if (affectedUserCount > 0) {
+ logger.info('Nullified cohortId for users', { count: affectedUserCount });
+ }🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Delete the orphaned cohort rows | ||
| await tx.delete(cohort).where(inArray(cohort.id, orphanedCohortIds)); | ||
| logger.info('Deleted orphaned cohorts', { | ||
| count: orphanedCohortIds.length, | ||
| }); | ||
| }); | ||
|
|
||
| return { affectedUserCount, deletedCohortIds: orphanedCohortIds }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Move orphan discovery into the same transaction as the destructive writes.
Line 26–38 computes
orphanedCohortIdsbefore Line 49 starts the transaction. A concurrent link insert/delete can stale that set and make the subsequent nullify/delete act on outdated membership.Proposed fix
export async function cleanupOrphanedCohorts(): Promise<OrphanCleanupSummary> { - // Find all cohort IDs that are referenced in the MTM table - const linkedRows = await db - .selectDistinct({ cohortId: cohortTimetableMtm.cohortId }) - .from(cohortTimetableMtm); - - const linkedSet = new Set(linkedRows.map((r) => r.cohortId)); - - // Find all cohort IDs - const allCohortRows = await db.select({ id: cohort.id }).from(cohort); - - // Cohorts whose ID does not appear in the MTM table are orphaned - const orphanedCohortIds = allCohortRows - .map((r) => r.id) - .filter((id) => !linkedSet.has(id)); - - if (orphanedCohortIds.length === 0) { - logger.info('No orphaned cohorts found.'); - return { affectedUserCount: 0, deletedCohortIds: [] }; - } - - logger.info('Found orphaned cohorts', { count: orphanedCohortIds.length }); - let affectedUserCount = 0; + let deletedCohortIds: string[] = []; await db.transaction(async (tx) => { + const linkedRows = await tx + .selectDistinct({ cohortId: cohortTimetableMtm.cohortId }) + .from(cohortTimetableMtm); + const linkedSet = new Set(linkedRows.map((r) => r.cohortId)); + + const allCohortRows = await tx.select({ id: cohort.id }).from(cohort); + const orphanedCohortIds = allCohortRows + .map((r) => r.id) + .filter((id) => !linkedSet.has(id)); + + if (orphanedCohortIds.length === 0) { + logger.info('No orphaned cohorts found.'); + return; + } + + deletedCohortIds = orphanedCohortIds; + // Nullify cohortId on users referencing orphaned cohorts const affectedUsers = await tx .select({ id: user.id }) @@ await tx.delete(cohort).where(inArray(cohort.id, orphanedCohortIds)); logger.info('Deleted orphaned cohorts', { count: orphanedCohortIds.length, }); }); - return { affectedUserCount, deletedCohortIds: orphanedCohortIds }; + return { affectedUserCount, deletedCohortIds }; }Also applies to: 49-71
🤖 Prompt for AI Agents