637 lines
20 KiB
TypeScript
637 lines
20 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { and, eq, inArray, isNotNull, isNull, lt, lte } from 'drizzle-orm';
|
|
import { config } from '../config.js';
|
|
import { getDb, getRawDb, schema } from '../db/index.js';
|
|
import { deleteUploadFile, deleteAttachmentFiles } from './fileCleanup.js';
|
|
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
|
|
|
|
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
|
|
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']);
|
|
const AUDIO_EXTS = new Set(['.mp3', '.ogg', '.wav', '.flac', '.aac', '.m4a', '.opus']);
|
|
const DOC_EXTS = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.json', '.xml']);
|
|
|
|
/** 1-hour threshold: attachments uploaded but never linked to a message */
|
|
const UNLINKED_AGE_MS = 60 * 60 * 1000;
|
|
|
|
function classifyFile(filename: string): string {
|
|
const ext = path.extname(filename).toLowerCase();
|
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
|
if (VIDEO_EXTS.has(ext)) return 'video';
|
|
if (AUDIO_EXTS.has(ext)) return 'audio';
|
|
if (DOC_EXTS.has(ext)) return 'document';
|
|
return 'other';
|
|
}
|
|
|
|
interface DiskFile {
|
|
filename: string;
|
|
size: number;
|
|
modifiedAt: number;
|
|
}
|
|
|
|
function getDiskFiles(): DiskFile[] {
|
|
try {
|
|
const entries = fs.readdirSync(config.uploadDir, { withFileTypes: true });
|
|
const files: DiskFile[] = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isFile()) continue;
|
|
try {
|
|
const stat = fs.statSync(path.join(config.uploadDir, entry.name));
|
|
files.push({
|
|
filename: entry.name,
|
|
size: stat.size,
|
|
modifiedAt: stat.mtimeMs,
|
|
});
|
|
} catch {
|
|
// Skip files that vanished between readdir and stat
|
|
}
|
|
}
|
|
return files;
|
|
} catch (err: any) {
|
|
if (err.code === 'ENOENT') return [];
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/** Filenames referenced by user/space profiles (avatars, banners, icons). */
|
|
function getProfileReferencedFilenames(): Set<string> {
|
|
const db = getDb();
|
|
const referenced = new Set<string>();
|
|
|
|
// User avatars
|
|
const avatarRows = db.select({ avatar: schema.users.avatar })
|
|
.from(schema.users)
|
|
.where(isNotNull(schema.users.avatar))
|
|
.all();
|
|
for (const row of avatarRows) {
|
|
if (row.avatar) referenced.add(path.basename(row.avatar));
|
|
}
|
|
|
|
// User banners
|
|
const bannerRows = db.select({ banner: schema.users.banner })
|
|
.from(schema.users)
|
|
.where(isNotNull(schema.users.banner))
|
|
.all();
|
|
for (const row of bannerRows) {
|
|
if (row.banner) referenced.add(path.basename(row.banner));
|
|
}
|
|
|
|
// Space icons
|
|
const iconRows = db.select({ icon: schema.spaces.icon })
|
|
.from(schema.spaces)
|
|
.where(isNotNull(schema.spaces.icon))
|
|
.all();
|
|
for (const row of iconRows) {
|
|
if (row.icon) referenced.add(path.basename(row.icon));
|
|
}
|
|
|
|
// Space banners
|
|
const spaceBannerRows = db.select({ banner: schema.spaces.banner })
|
|
.from(schema.spaces)
|
|
.where(isNotNull(schema.spaces.banner))
|
|
.all();
|
|
for (const row of spaceBannerRows) {
|
|
if (row.banner) referenced.add(path.basename(row.banner));
|
|
}
|
|
|
|
return referenced;
|
|
}
|
|
|
|
/** All filenames referenced anywhere: attachments + profiles. */
|
|
function getReferencedFilenames(): Set<string> {
|
|
const db = getDb();
|
|
const referenced = getProfileReferencedFilenames();
|
|
|
|
// Attachment filenames
|
|
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
|
.from(schema.attachments).all();
|
|
for (const row of attachmentRows) {
|
|
referenced.add(path.basename(row.filename));
|
|
}
|
|
|
|
// Attachment thumbnails
|
|
const thumbRows = db.select({ thumbnailFilename: schema.attachments.thumbnailFilename })
|
|
.from(schema.attachments)
|
|
.where(isNotNull(schema.attachments.thumbnailFilename))
|
|
.all();
|
|
for (const row of thumbRows) {
|
|
if (row.thumbnailFilename) referenced.add(path.basename(row.thumbnailFilename));
|
|
}
|
|
|
|
return referenced;
|
|
}
|
|
|
|
function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFilename: string | null; size: number }[] {
|
|
const db = getDb();
|
|
const cutoff = Date.now() - UNLINKED_AGE_MS;
|
|
const profileReferenced = getProfileReferencedFilenames();
|
|
|
|
// Attachments with no message_id AND no dm_message_id, older than 1 hour,
|
|
// excluding files currently used as profile images (avatars, banners, icons)
|
|
const rows = db.select({
|
|
id: schema.attachments.id,
|
|
filename: schema.attachments.filename,
|
|
thumbnailFilename: schema.attachments.thumbnailFilename,
|
|
size: schema.attachments.size,
|
|
messageId: schema.attachments.messageId,
|
|
dmMessageId: schema.attachments.dmMessageId,
|
|
createdAt: schema.attachments.createdAt,
|
|
}).from(schema.attachments).all();
|
|
|
|
return rows.filter(r =>
|
|
r.messageId === null && r.dmMessageId === null && r.createdAt < cutoff
|
|
&& !profileReferenced.has(path.basename(r.filename))
|
|
).map(r => ({
|
|
id: r.id,
|
|
filename: r.filename,
|
|
thumbnailFilename: r.thumbnailFilename,
|
|
size: r.size,
|
|
}));
|
|
}
|
|
|
|
/** Attachment records whose message_id or dm_message_id references a deleted message. */
|
|
function getDanglingAttachments(): { id: string; filename: string; size: number }[] {
|
|
const rawDb = getRawDb();
|
|
|
|
// Space message attachments with dangling references
|
|
const danglingSpace = rawDb.prepare(`
|
|
SELECT a.id, a.filename, a.size
|
|
FROM attachments a
|
|
LEFT JOIN messages m ON a.message_id = m.id
|
|
WHERE a.message_id IS NOT NULL AND m.id IS NULL
|
|
`).all() as { id: string; filename: string; size: number }[];
|
|
|
|
// DM message attachments with dangling references
|
|
const danglingDm = rawDb.prepare(`
|
|
SELECT a.id, a.filename, a.size
|
|
FROM attachments a
|
|
LEFT JOIN dm_messages dm ON a.dm_message_id = dm.id
|
|
WHERE a.dm_message_id IS NOT NULL AND dm.id IS NULL
|
|
`).all() as { id: string; filename: string; size: number }[];
|
|
|
|
return [...danglingSpace, ...danglingDm];
|
|
}
|
|
|
|
export function getStorageStats(): StorageStats {
|
|
const diskFiles = getDiskFiles();
|
|
const referenced = getReferencedFilenames();
|
|
const unlinked = getUnlinkedAttachments();
|
|
const dangling = getDanglingAttachments();
|
|
|
|
const danglingFilenames = new Set<string>();
|
|
let danglingSize = 0;
|
|
for (const att of dangling) {
|
|
danglingFilenames.add(path.basename(att.filename));
|
|
danglingSize += att.size;
|
|
}
|
|
|
|
let totalSize = 0;
|
|
let referencedSize = 0;
|
|
let orphanedFiles = 0;
|
|
let orphanedSize = 0;
|
|
let danglingFilesOnDisk = 0;
|
|
const breakdownMap = new Map<string, { count: number; size: number }>();
|
|
|
|
for (const file of diskFiles) {
|
|
totalSize += file.size;
|
|
const type = classifyFile(file.filename);
|
|
const entry = breakdownMap.get(type) || { count: 0, size: 0 };
|
|
entry.count++;
|
|
entry.size += file.size;
|
|
breakdownMap.set(type, entry);
|
|
|
|
if (danglingFilenames.has(file.filename)) {
|
|
// File is on disk but its attachment record points to a deleted message
|
|
danglingFilesOnDisk++;
|
|
} else if (referenced.has(file.filename)) {
|
|
referencedSize += file.size;
|
|
} else {
|
|
orphanedFiles++;
|
|
orphanedSize += file.size;
|
|
}
|
|
}
|
|
|
|
let unlinkedSize = 0;
|
|
for (const att of unlinked) {
|
|
unlinkedSize += att.size;
|
|
}
|
|
|
|
const breakdown: StorageBreakdown[] = [];
|
|
for (const [type, data] of breakdownMap) {
|
|
breakdown.push({ type, count: data.count, size: data.size });
|
|
}
|
|
breakdown.sort((a, b) => b.size - a.size);
|
|
|
|
return {
|
|
totalFiles: diskFiles.length,
|
|
totalSize,
|
|
referencedFiles: diskFiles.length - orphanedFiles - danglingFilesOnDisk,
|
|
referencedSize,
|
|
orphanedFiles,
|
|
orphanedSize,
|
|
unlinkedAttachments: unlinked.length,
|
|
unlinkedSize,
|
|
danglingAttachments: dangling.length,
|
|
danglingSize,
|
|
breakdown,
|
|
};
|
|
}
|
|
|
|
export function getOrphanedFiles(): OrphanedFile[] {
|
|
const diskFiles = getDiskFiles();
|
|
const referenced = getReferencedFilenames();
|
|
|
|
const orphans: OrphanedFile[] = [];
|
|
for (const file of diskFiles) {
|
|
if (!referenced.has(file.filename)) {
|
|
orphans.push({
|
|
filename: file.filename,
|
|
size: file.size,
|
|
modifiedAt: file.modifiedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
orphans.sort((a, b) => b.size - a.size);
|
|
return orphans;
|
|
}
|
|
|
|
export function cleanupStorage(dryRun: boolean): CleanupResult {
|
|
const db = getDb();
|
|
const orphans = getOrphanedFiles();
|
|
const unlinked = getUnlinkedAttachments();
|
|
const profileReferenced = getProfileReferencedFilenames();
|
|
const errors: string[] = [];
|
|
let deletedFiles = 0;
|
|
let freedBytes = 0;
|
|
let deletedAttachmentRecords = 0;
|
|
|
|
// Phase 1: Delete orphaned disk files (not referenced by any DB record)
|
|
for (const orphan of orphans) {
|
|
if (!dryRun) {
|
|
try {
|
|
deleteUploadFile(orphan.filename);
|
|
} catch (err: any) {
|
|
errors.push(`Failed to delete ${orphan.filename}: ${err.message}`);
|
|
continue;
|
|
}
|
|
}
|
|
deletedFiles++;
|
|
freedBytes += orphan.size;
|
|
}
|
|
|
|
// Phase 2: Clean up stale unlinked attachment records.
|
|
// Files referenced by user/space profiles (avatars, banners, icons) are
|
|
// preserved on disk — only the orphaned attachment DB record is removed.
|
|
// Thumbnails are always deleted since profile images don't need them.
|
|
for (const att of unlinked) {
|
|
const fileInUseByProfile = profileReferenced.has(path.basename(att.filename));
|
|
if (!dryRun) {
|
|
try {
|
|
if (!fileInUseByProfile) {
|
|
deleteUploadFile(att.filename);
|
|
} else if (att.thumbnailFilename) {
|
|
// Main file is a profile image — keep it. But delete the thumbnail
|
|
// since it's only useful for message attachments, and the attachment
|
|
// record is about to be deleted (which would orphan the thumbnail).
|
|
const thumbPath = path.join(config.uploadDir, path.basename(att.thumbnailFilename));
|
|
try { fs.unlinkSync(thumbPath); } catch { /* may not exist */ }
|
|
}
|
|
db.delete(schema.attachments)
|
|
.where(eq(schema.attachments.id, att.id))
|
|
.run();
|
|
} catch (err: any) {
|
|
errors.push(`Failed to clean up attachment ${att.id}: ${err.message}`);
|
|
continue;
|
|
}
|
|
}
|
|
deletedAttachmentRecords++;
|
|
if (!fileInUseByProfile) {
|
|
freedBytes += att.size;
|
|
}
|
|
}
|
|
|
|
// Phase 3: Delete dangling attachment records (message_id / dm_message_id
|
|
// points to a message that no longer exists) and their files from disk.
|
|
const dangling = getDanglingAttachments();
|
|
for (const att of dangling) {
|
|
if (!dryRun) {
|
|
try {
|
|
deleteUploadFile(att.filename);
|
|
db.delete(schema.attachments)
|
|
.where(eq(schema.attachments.id, att.id))
|
|
.run();
|
|
} catch (err: any) {
|
|
errors.push(`Failed to clean up dangling attachment ${att.id}: ${err.message}`);
|
|
continue;
|
|
}
|
|
}
|
|
deletedFiles++;
|
|
freedBytes += att.size;
|
|
deletedAttachmentRecords++;
|
|
}
|
|
|
|
return {
|
|
dryRun,
|
|
deletedFiles,
|
|
freedBytes,
|
|
deletedAttachmentRecords,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
export function cleanupOldMedia(maxAgeDays: number, dryRun: boolean): CleanupResult {
|
|
const db = getDb();
|
|
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
const profileReferenced = getProfileReferencedFilenames();
|
|
const errors: string[] = [];
|
|
let deletedFiles = 0;
|
|
let freedBytes = 0;
|
|
let deletedAttachmentRecords = 0;
|
|
|
|
// Find message attachments older than the cutoff
|
|
const rawDb = getRawDb();
|
|
const oldMedia = rawDb.prepare(`
|
|
SELECT id, filename, size FROM attachments
|
|
WHERE (message_id IS NOT NULL OR dm_message_id IS NOT NULL)
|
|
AND created_at < ?
|
|
`).all(cutoff) as { id: string; filename: string; size: number }[];
|
|
|
|
for (const att of oldMedia) {
|
|
// Never delete files currently used as profile images
|
|
if (profileReferenced.has(path.basename(att.filename))) continue;
|
|
|
|
if (!dryRun) {
|
|
try {
|
|
deleteUploadFile(att.filename);
|
|
db.delete(schema.attachments)
|
|
.where(eq(schema.attachments.id, att.id))
|
|
.run();
|
|
} catch (err: any) {
|
|
errors.push(`Failed to delete old media ${att.id}: ${err.message}`);
|
|
continue;
|
|
}
|
|
}
|
|
deletedFiles++;
|
|
freedBytes += att.size;
|
|
deletedAttachmentRecords++;
|
|
}
|
|
|
|
return { dryRun, deletedFiles, freedBytes, deletedAttachmentRecords, errors };
|
|
}
|
|
|
|
/**
|
|
* Delete expired federation outbox entries (expiresAt < now).
|
|
* Returns the number of rows deleted.
|
|
*/
|
|
export function cleanupFederationOutbox(): number {
|
|
const db = getDb();
|
|
const result = db.delete(schema.federationOutbox)
|
|
.where(lt(schema.federationOutbox.expiresAt, Date.now()))
|
|
.run();
|
|
return result.changes;
|
|
}
|
|
|
|
/**
|
|
* Delete federation mutation log entries older than `retentionDays` (default 90).
|
|
* Returns the number of rows deleted.
|
|
*/
|
|
export function cleanupFederationMutationLog(retentionDays: number = 90): number {
|
|
const db = getDb();
|
|
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
const result = db.delete(schema.federationMutationLog)
|
|
.where(lt(schema.federationMutationLog.mutatedAt, cutoff))
|
|
.run();
|
|
return result.changes;
|
|
}
|
|
|
|
/**
|
|
* Delete stale federation file queue entries:
|
|
* - completed entries older than 7 days
|
|
* - any entries whose expiresAt has passed (regardless of status)
|
|
* Returns the total number of rows deleted.
|
|
*/
|
|
export function cleanupFederationFileQueue(): number {
|
|
const db = getDb();
|
|
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
|
|
// Completed entries older than 7 days
|
|
const completed = db.delete(schema.federationFileQueue)
|
|
.where(and(
|
|
eq(schema.federationFileQueue.status, 'completed'),
|
|
lt(schema.federationFileQueue.createdAt, sevenDaysAgo),
|
|
))
|
|
.run();
|
|
|
|
// Expired entries (any status)
|
|
const expired = db.delete(schema.federationFileQueue)
|
|
.where(lt(schema.federationFileQueue.expiresAt, Date.now()))
|
|
.run();
|
|
|
|
return completed.changes + expired.changes;
|
|
}
|
|
|
|
/**
|
|
* Expire peer approval requests older than their expiresAt timestamp.
|
|
* For each expired request, attempt to send a signed denial notification
|
|
* to the requesting instance before deleting. If notification fails,
|
|
* leave the record for the next janitor cycle.
|
|
*/
|
|
export async function cleanupExpiredApprovalRequests(): Promise<number> {
|
|
const db = getDb();
|
|
const expired = db
|
|
.select()
|
|
.from(schema.peerApprovalRequests)
|
|
.where(lte(schema.peerApprovalRequests.expiresAt, Date.now()))
|
|
.all();
|
|
|
|
if (expired.length === 0) return 0;
|
|
|
|
const { getOurOrigin, buildFederationHeaders } = await import('./federationAuth.js');
|
|
const ourOrigin = getOurOrigin();
|
|
let cleaned = 0;
|
|
|
|
for (const req of expired) {
|
|
const denialBody = JSON.stringify({
|
|
origin: ourOrigin,
|
|
reason: 'expired' as const,
|
|
message: 'Request expired — no response from admin within 30 days',
|
|
});
|
|
|
|
const headers = buildFederationHeaders(denialBody, req.hmacSecret, ourOrigin);
|
|
|
|
let sent = false;
|
|
try {
|
|
const response = await fetch(`${req.origin}/api/federation/peer/denied`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: denialBody,
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
sent = response.ok;
|
|
} catch {
|
|
// Network error — will retry next cycle
|
|
}
|
|
|
|
if (sent) {
|
|
// No rejected peer record for expiry — origin can re-request
|
|
db.delete(schema.peerApprovalRequests)
|
|
.where(eq(schema.peerApprovalRequests.id, req.id))
|
|
.run();
|
|
cleaned++;
|
|
} else {
|
|
console.warn(`[storage-janitor] Failed to send expiry denial to ${req.origin} — will retry next cycle`);
|
|
}
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
/**
|
|
* Hard-delete DM channels that were soft-deleted more than 24 hours ago.
|
|
* Cascades: reactions, embeds, attachments (files + DB rows), messages,
|
|
* members, outbox entries, mutation log entries, file queue entries, then the channel.
|
|
* Returns the number of channels purged.
|
|
*/
|
|
export function cleanupSoftDeletedDmChannels(): number {
|
|
const db = getDb();
|
|
const gracePeriodMs = 24 * 60 * 60 * 1000; // 24 hours
|
|
const cutoff = Date.now() - gracePeriodMs;
|
|
|
|
const expired = db
|
|
.select({ id: schema.dmChannels.id })
|
|
.from(schema.dmChannels)
|
|
.where(
|
|
and(
|
|
isNotNull(schema.dmChannels.deletedAt),
|
|
lt(schema.dmChannels.deletedAt, cutoff),
|
|
),
|
|
)
|
|
.all();
|
|
|
|
if (expired.length === 0) return 0;
|
|
|
|
let purged = 0;
|
|
|
|
for (const channel of expired) {
|
|
try {
|
|
// Get message IDs and attachment filenames before deletion
|
|
const msgIds = db.select({ id: schema.dmMessages.id })
|
|
.from(schema.dmMessages)
|
|
.where(eq(schema.dmMessages.dmChannelId, channel.id))
|
|
.all()
|
|
.map(m => m.id);
|
|
|
|
const filesToDelete: string[] = [];
|
|
if (msgIds.length > 0) {
|
|
const attachments = db.select({ filename: schema.attachments.filename })
|
|
.from(schema.attachments)
|
|
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
|
.all();
|
|
filesToDelete.push(...attachments.map(a => a.filename));
|
|
}
|
|
|
|
db.transaction((tx) => {
|
|
if (msgIds.length > 0) {
|
|
// Delete reactions
|
|
tx.delete(schema.dmReactions)
|
|
.where(inArray(schema.dmReactions.dmMessageId, msgIds))
|
|
.run();
|
|
|
|
// Delete embeds
|
|
tx.delete(schema.embeds)
|
|
.where(inArray(schema.embeds.dmMessageId, msgIds))
|
|
.run();
|
|
|
|
// Delete attachments (DB rows)
|
|
tx.delete(schema.attachments)
|
|
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
|
.run();
|
|
|
|
// Delete file queue entries
|
|
tx.delete(schema.federationFileQueue)
|
|
.where(inArray(schema.federationFileQueue.dmMessageId, msgIds))
|
|
.run();
|
|
}
|
|
|
|
// Delete messages
|
|
tx.delete(schema.dmMessages)
|
|
.where(eq(schema.dmMessages.dmChannelId, channel.id))
|
|
.run();
|
|
|
|
// Delete members (should be 0, defensive)
|
|
tx.delete(schema.dmMembers)
|
|
.where(eq(schema.dmMembers.dmChannelId, channel.id))
|
|
.run();
|
|
|
|
// Delete read states
|
|
tx.delete(schema.readStates)
|
|
.where(eq(schema.readStates.channelId, channel.id))
|
|
.run();
|
|
|
|
// Delete federation outbox entries
|
|
tx.delete(schema.federationOutbox)
|
|
.where(eq(schema.federationOutbox.contextId, channel.id))
|
|
.run();
|
|
|
|
// Delete mutation log entries
|
|
tx.delete(schema.federationMutationLog)
|
|
.where(eq(schema.federationMutationLog.contextId, channel.id))
|
|
.run();
|
|
|
|
// Delete the channel itself
|
|
tx.delete(schema.dmChannels)
|
|
.where(eq(schema.dmChannels.id, channel.id))
|
|
.run();
|
|
});
|
|
|
|
// Clean up files from disk (outside transaction — filesystem ops are idempotent)
|
|
deleteAttachmentFiles(filesToDelete.map(f => ({ filename: f })));
|
|
|
|
purged++;
|
|
} catch (err) {
|
|
console.error(`[storage-janitor] Failed to purge soft-deleted DM channel ${channel.id}:`, err);
|
|
}
|
|
}
|
|
|
|
if (purged > 0) {
|
|
console.log(`[storage-janitor] Purged ${purged} soft-deleted DM channels`);
|
|
}
|
|
|
|
return purged;
|
|
}
|
|
|
|
/**
|
|
* Run all periodic federation/GC cleanup tasks:
|
|
* - Expired outbox entries
|
|
* - Old mutation log entries
|
|
* - Stale file queue entries
|
|
* - Soft-deleted DM channels past grace period
|
|
*/
|
|
export function runFederationJanitor(): void {
|
|
try {
|
|
const outbox = cleanupFederationOutbox();
|
|
const mutLog = cleanupFederationMutationLog();
|
|
const fileQ = cleanupFederationFileQueue();
|
|
const dmGc = cleanupSoftDeletedDmChannels();
|
|
|
|
const total = outbox + mutLog + fileQ + dmGc;
|
|
if (total > 0) {
|
|
console.log(
|
|
`[storage-janitor] Federation GC sweep: outbox=${outbox} mutationLog=${mutLog} fileQueue=${fileQ} dmChannels=${dmGc}`,
|
|
);
|
|
}
|
|
|
|
// Async: expire approval requests (sends network notifications)
|
|
cleanupExpiredApprovalRequests().then((approvalExpired) => {
|
|
if (approvalExpired > 0) {
|
|
console.log(`[storage-janitor] Expired ${approvalExpired} peer approval request(s)`);
|
|
}
|
|
}).catch((err) => {
|
|
console.error('[storage-janitor] Approval request expiry error:', err);
|
|
});
|
|
} catch (err) {
|
|
console.error('[storage-janitor] Federation GC sweep error:', err);
|
|
}
|
|
}
|