Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion apps/server/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/storage/facade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/workspaces/logo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
18 changes: 18 additions & 0 deletions apps/server/test/integration/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<svg xmlns='http://www.w3.org/2000/svg'/>"), 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);
});
});
Loading