diff --git a/src/cleanup/cleanup.service.ts b/src/cleanup/cleanup.service.ts index 13cab65..d882fa4 100644 --- a/src/cleanup/cleanup.service.ts +++ b/src/cleanup/cleanup.service.ts @@ -2,13 +2,35 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { supabaseAdmin } from '../supabase/supabase.client'; +/** + * DRY_RUN=true: logs what would be deleted without executing DELETE. + * CLEANUP_ENABLED=false: disables all cleanup crons (staging/dev safety valve). + */ @Injectable() export class CleanupService { private readonly logger = new Logger(CleanupService.name); + private readonly dryRun = process.env.DRY_RUN === 'true'; + private readonly enabled = process.env.CLEANUP_ENABLED !== 'false'; + + private log(msg: string) { + this.logger.log(this.dryRun ? `[DRY_RUN] ${msg}` : msg); + } @Cron('0 4 * * *') async cleanTerminalSagas() { + if (!this.enabled) return; const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + // Pre-flight count + const { count: preview } = await supabaseAdmin + .from('saga_instances') + .select('id', { count: 'exact', head: true }) + .in('status', ['completed', 'failed', 'compensated']) + .lt('updated_at', cutoff); + this.log(`saga cleanup: ${preview ?? 0} terminal instances eligible`); + + if (this.dryRun) return; + const { error, count } = await supabaseAdmin .from('saga_instances') .delete({ count: 'exact' }) @@ -20,8 +42,23 @@ export class CleanupService { @Cron('0 3 * * *') async cleanExpiredNotifications() { + if (!this.enabled) return; const now = new Date().toISOString(); const cutoff90 = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(); + + const { count: expiredCount } = await supabaseAdmin + .from('notifications') + .select('id', { count: 'exact', head: true }) + .not('expires_at', 'is', null) + .lt('expires_at', now); + const { count: oldCount } = await supabaseAdmin + .from('notifications') + .select('id', { count: 'exact', head: true }) + .lt('created_at', cutoff90); + this.log(`notification cleanup: ${(expiredCount ?? 0) + (oldCount ?? 0)} eligible`); + + if (this.dryRun) return; + const { error: e1, count: c1 } = await supabaseAdmin .from('notifications') .delete({ count: 'exact' }) @@ -37,7 +74,18 @@ export class CleanupService { @Cron('0 3 1 * *') async cleanProcessedOutbox() { + if (!this.enabled) return; const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + const { count: preview } = await supabaseAdmin + .from('outbox_events') + .select('id', { count: 'exact', head: true }) + .not('processed_at', 'is', null) + .lt('processed_at', cutoff); + this.log(`outbox cleanup: ${preview ?? 0} processed events eligible`); + + if (this.dryRun) return; + const { error, count } = await supabaseAdmin .from('outbox_events') .delete({ count: 'exact' })