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
@@ -13,9 +13,11 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService));
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService), asService);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const DAY_MS = 24 * 60 * 60 * 1000;
// point storage at an isolated temp dir for the test run
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
@@ -70,3 +72,51 @@ describe('MediaService (presigned local storage)', () => {
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
});
});
describe('MediaService orphan sweep', () => {
async function upload(s: MediaService): Promise<string> {
const bytes = Buffer.from('orphan-candidate');
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
await s.put(tokenFrom(uploadUrl), bytes);
return objectKey;
}
it('put() records a tracking row for the stored object', async () => {
const key = await upload(svc());
const row = await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } });
expect(row).not.toBeNull();
expect(row!.mime).toBe('image/png');
});
it('reaps an object that is past the grace window and unreferenced', async () => {
const s = svc();
const key = await upload(s);
// Simulate the grace window having elapsed by sweeping "in the future".
const deleted = await s.sweepOrphans(Date.now() + 2 * DAY_MS);
expect(deleted).toBe(1);
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).toBeNull();
});
it('keeps an object still within the grace window', async () => {
const s = svc();
const key = await upload(s);
expect(await s.sweepOrphans(Date.now())).toBe(0);
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
});
it('keeps an object a message part references, even past grace', async () => {
const s = svc();
const key = await upload(s);
const scope = await new ActorResolver(asService).resolveScope(alice);
await prisma.iiosInteraction.create({
data: {
scopeId: scope.id,
kind: 'MESSAGE',
idempotencyKey: 'ref-1',
parts: { create: [{ partIndex: 0, kind: 'MEDIA_REF', contentRef: key }] },
},
});
expect(await s.sweepOrphans(Date.now() + 2 * DAY_MS)).toBe(0);
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
});
});