diff --git a/apps/server/src/storage.ts b/apps/server/src/storage.ts index 0272648..f267f0b 100644 --- a/apps/server/src/storage.ts +++ b/apps/server/src/storage.ts @@ -174,7 +174,19 @@ export async function sweepOrphanObjects( ); const revisions = await client.documentRevision.findMany({ where: { prunedAt: null }, select: { storageKey: true } }); const attachments = await client.attachment.findMany({ where: { deletedAt: null }, select: { storageKey: true } }); - const referenced = new Set([...revisions.map((r) => r.storageKey), ...attachments.map((a) => a.storageKey)]); + // Profile-style blobs (workspace logos) are written through writeBlob, so their keys match + // ATTACHMENT_KEY_RE and the sweep lists them — but they are owned by a column on another table, + // not by an Attachment row. Every owner of a content-addressed key must be enumerated here or + // the sweep deletes a live blob and the read path 404s a logo the user did upload. + const logos = await client.workspace.findMany({ + where: { logoStorageKey: { not: null } }, + select: { logoStorageKey: true }, + }); + const referenced = new Set([ + ...revisions.map((r) => r.storageKey), + ...attachments.map((a) => a.storageKey), + ...logos.map((w) => w.logoStorageKey as string), + ]); const now = Date.now(); let removed = 0; let kept = 0; diff --git a/apps/server/src/storage/facade.test.ts b/apps/server/src/storage/facade.test.ts index 938d564..e65e600 100644 --- a/apps/server/src/storage/facade.test.ts +++ b/apps/server/src/storage/facade.test.ts @@ -75,6 +75,7 @@ describe("storage facade with an injected backend", () => { const fakeClient = { documentRevision: { findMany: async () => [{ storageKey: referenced.storageKey }] }, attachment: { findMany: async () => [] }, + workspace: { findMany: async () => [] }, } as never; const res = await storage.sweepOrphanObjects(fakeClient, 60 * 60 * 1000); diff --git a/apps/server/src/workspaces/logo.ts b/apps/server/src/workspaces/logo.ts index 9f4e13e..ad8c89e 100644 --- a/apps/server/src/workspaces/logo.ts +++ b/apps/server/src/workspaces/logo.ts @@ -179,6 +179,13 @@ export async function registerWorkspaceLogoRoutes(app: FastifyInstance): Promise bytes = await readBlob(ws.logoStorageKey); } catch (err) { if (!(err instanceof StorageNotFoundError)) throw err; + // The row pointed at a blob that is no longer in storage. Clearing the columns keeps the + // UI coherent, but it also erases the only evidence that a logo was ever uploaded — so log + // loudly first. A burst of these means something is deleting live blobs. + request.log.warn( + { workspaceId: request.params.id, storageKey: ws.logoStorageKey }, + "workspace logo blob missing from storage; clearing logo columns", + ); await prisma.workspace.updateMany({ where: { id: request.params.id, logoStorageKey: ws.logoStorageKey }, data: { logoStorageKey: null, logoContentType: null, logoSha: null }, diff --git a/apps/server/test/integration/storage.test.ts b/apps/server/test/integration/storage.test.ts index 267ffe3..5bd63c3 100644 --- a/apps/server/test/integration/storage.test.ts +++ b/apps/server/test/integration/storage.test.ts @@ -134,4 +134,22 @@ describe("storage atomicity + orphan sweep", () => { expect(existsSync(join(process.env.STORAGE_ROOT!, liveBlob.storageKey))).toBe(true); expect(existsSync(join(process.env.STORAGE_ROOT!, deletedBlob.storageKey))).toBe(false); }); + + it("keeps a workspace logo blob — it is referenced by a column, not an Attachment row", async () => { + const s = await baseScenario(); + const logo = await writeBlob(Buffer.from(""), s.ws.id); + await prisma.workspace.update({ + where: { id: s.ws.id }, + data: { logoStorageKey: logo.storageKey, logoContentType: "image/svg+xml", logoSha: logo.hex }, + }); + + // No grace period: the sweep must keep this blob because it is referenced, not because it is new. + const swept = await sweepOrphanObjects(prisma, 0); + + expect(existsSync(join(process.env.STORAGE_ROOT!, logo.storageKey))).toBe(true); + expect(swept.kept).toBeGreaterThanOrEqual(1); + // And the pointer survives: the read path clears these columns when the blob is gone. + const after = await prisma.workspace.findUnique({ where: { id: s.ws.id }, select: { logoStorageKey: true } }); + expect(after?.logoStorageKey).toBe(logo.storageKey); + }); });