feat(media): orphan cleanup — track objects + reap unreferenced attachments

Attachments upload direct-to-storage on attach, so an abandoned attach (removed,
cancelled, tab closed) would linger forever. Add IiosMediaObject: a row is recorded
when bytes land (MediaService.put); a scheduled sweepOrphans() reaps objects past a
grace window (IIOS_MEDIA_ORPHAN_GRACE_MS, default 24h) that no IiosMessagePart
references (derived live — no flag to drift). Storage delete via the StoragePort;
best-effort + idempotent. Gated on IIOS_MEDIA_GC_INTERVAL_MS (off by default).
Migration 20260722193958_media_object_tracking. 10 media tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 01:19:11 +05:30
parent 22eaf0f654
commit 0ee63c139f
5 changed files with 161 additions and 4 deletions
@@ -543,6 +543,22 @@ model IiosMessagePart {
@@unique([interactionId, partIndex])
}
/// A stored media object (bytes behind the StoragePort). A row is recorded when bytes actually
/// land (MediaService.put), so an orphan sweep can reap objects that no message part references —
/// e.g. a file attached in a composer but never sent. "Referenced" is derived at sweep time from
/// IiosMessagePart.contentRef (no stored flag to drift), after a grace period so in-progress
/// composes are never reaped.
model IiosMediaObject {
id String @id @default(cuid())
scopeId String
objectKey String @unique
mime String
sizeBytes BigInt
createdAt DateTime @default(now())
@@index([createdAt])
}
/// Transactional outbox — written in the same tx as the business row.
model IiosOutboxEvent {
eventId String @id @default(cuid())