account-deletion-gc.ts66 lines · main
1import { env } from '../env.js';
2import { log } from '../lib/logger.js';
3import { hardDeleteExpiredAccounts } from '../services/account-deletion.js';
4
5/**
6 * Hard-delete-after-grace-window cron. Soft-deleted accounts have a
7 * 30-day reversal window during which operator support can revert
8 * via SQL. After that, this worker scans for rows whose
9 * `users.deleted_at` is older than the threshold and DELETEs them —
10 * cascades fire via the FK ON DELETE rules already in the schema.
11 *
12 * Daily cadence is enough — the threshold is 30 days; running every
13 * hour would just churn the same predicate. Aligned to ~03:30 UTC so
14 * it doesn't compete with the hourly usage aggregator (xx:05).
15 *
16 * Idempotent: a re-run after a crash mid-run is safe.
17 */
18
19const DAY_MS = 24 * 60 * 60 * 1000;
20const TARGET_UTC_HOUR = 3;
21const TARGET_UTC_MINUTE = 30;
22
23let timer: ReturnType<typeof setInterval> | null = null;
24
25function nextRunOffsetMs(now: Date = new Date()): number {
26 const next = new Date(now);
27 next.setUTCHours(TARGET_UTC_HOUR, TARGET_UTC_MINUTE, 0, 0);
28 if (next.getTime() <= now.getTime()) {
29 next.setUTCDate(next.getUTCDate() + 1);
30 }
31 return next.getTime() - now.getTime();
32}
33
34async function tick(): Promise<void> {
35 try {
36 const deleted = await hardDeleteExpiredAccounts({ graceDays: 30 });
37 if (deleted > 0) {
38 log.info('account_deletion_gc_tick', { deleted });
39 }
40 } catch (err) {
41 log.error('account_deletion_gc_failed', {
42 message: err instanceof Error ? err.message : String(err),
43 });
44 }
45}
46
47/**
48 * Start the daily account-deletion garbage collector. Idempotent — a
49 * second call is a no-op. Skipped entirely when the database isn't
50 * configured (dev / boot before migrations).
51 */
52export function startAccountDeletionGc(): void {
53 if (timer) return;
54 if (!env.BRIVEN_DATABASE_URL) {
55 log.warn('account_deletion_gc_skipped_no_db');
56 return;
57 }
58 const offset = nextRunOffsetMs();
59 setTimeout(() => {
60 void tick();
61 timer = setInterval(() => {
62 void tick();
63 }, DAY_MS);
64 }, offset).unref?.();
65 log.info('account_deletion_gc_armed', { firstRunInMs: offset });
66}