feat(attachments): R2 toggle, storage metrics, and demo R2 config

Add an ATTACHMENTS_ENABLED switch (default on when R2 is bound) via a
central getAttachmentBucket helper, surface R2 + estimated KV usage
against the free tier on the status page and /api/stats (refreshed by the
hourly cron), let setup.sh create and wire the R2 bucket, and bind the
demo bucket so the deployed demo has attachments.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-23 17:33:50 +02:00
1 parent 7226e718f7
commit f150d40c45
19 files changed
+387 -23

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
import { Env } from "../types";
// Returns the attachment bucket only when the feature is enabled, so callers can
// narrow cleanly. Attachments are on whenever R2 is bound, unless explicitly
// turned off with ATTACHMENTS_ENABLED="false".
export function getAttachmentBucket(env: Env): R2Bucket | undefined {
if (env.ATTACHMENTS_ENABLED === "false") return undefined;
return env.ATTACHMENT_BUCKET;
}
+8
View File
@@ -0,0 +1,8 @@
/** Human-readable byte size (B / KB / MB / GB). */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
+96 -1
View File
@@ -1,11 +1,15 @@
import { describe, it, expect } from "vitest";
import { createMockEnv } from "../test/setup";
import { createMockEnv, MockR2 } from "../test/setup";
import {
getCounters,
bumpCounters,
countKeysByPrefix,
getStats,
scanR2Usage,
scanKvUsage,
setStorageSnapshot,
} from "./stats";
import { getAttachmentBucket } from "./attachments";
import { STATS_KEY, FEEDS_LIST_KEY } from "../config/constants";
import { Env } from "../types";
@@ -119,4 +123,95 @@ describe("stats helper", () => {
};
expect(raw.feeds_created).toBe(1);
});
it("getStats reports attachments_enabled based on the toggle", async () => {
const off = createMockEnv() as unknown as Env;
expect((await getStats(off)).attachments_enabled).toBe(false);
const on = createMockEnv({ withR2: true }) as unknown as Env;
expect((await getStats(on)).attachments_enabled).toBe(true);
const disabled = createMockEnv({ withR2: true }) as unknown as Env;
(disabled as any).ATTACHMENTS_ENABLED = "false";
expect((await getStats(disabled)).attachments_enabled).toBe(false);
});
});
describe("getAttachmentBucket", () => {
it("returns the bucket when bound and not disabled", () => {
const env = createMockEnv({ withR2: true }) as unknown as Env;
expect(getAttachmentBucket(env)).toBeDefined();
});
it("returns undefined when no bucket is bound", () => {
const env = createMockEnv() as unknown as Env;
expect(getAttachmentBucket(env)).toBeUndefined();
});
it("returns undefined when explicitly disabled", () => {
const env = createMockEnv({ withR2: true }) as unknown as Env;
(env as any).ATTACHMENTS_ENABLED = "false";
expect(getAttachmentBucket(env)).toBeUndefined();
});
});
describe("storage usage scans", () => {
it("scanR2Usage sums object sizes and counts", async () => {
const bucket = new MockR2();
await bucket.put("a", new Uint8Array(100));
await bucket.put("b", new Uint8Array(250));
const usage = await scanR2Usage(bucket as unknown as R2Bucket);
expect(usage.count).toBe(2);
expect(usage.bytes).toBe(350);
});
it("scanR2Usage returns zeros for an empty bucket", async () => {
const usage = await scanR2Usage(new MockR2() as unknown as R2Bucket);
expect(usage).toEqual({ bytes: 0, count: 0 });
});
it("scanKvUsage estimates KV bytes from stored email sizes", async () => {
const env = createMockEnv() as unknown as Env;
const kv = env.EMAIL_STORAGE;
await kv.put(
FEEDS_LIST_KEY,
JSON.stringify({
feeds: [
{ id: "a", title: "A" },
{ id: "b", title: "B" },
],
}),
);
await kv.put(
"feed:a:metadata",
JSON.stringify({
emails: [
{ key: "k1", size: 100 },
{ key: "k2", size: 50 },
],
}),
);
await kv.put(
"feed:b:metadata",
JSON.stringify({ emails: [{ key: "k3", size: 25 }] }),
);
const usage = await scanKvUsage(kv);
expect(usage.bytes).toBe(175);
});
it("setStorageSnapshot writes the snapshot fields", async () => {
const env = createMockEnv() as unknown as Env;
const kv = env.EMAIL_STORAGE;
await setStorageSnapshot(kv, {
attachments_bytes: 1234,
attachments_count: 5,
kv_bytes_estimated: 678,
});
const counters = await getCounters(kv);
expect(counters.attachments_bytes).toBe(1234);
expect(counters.attachments_count).toBe(5);
expect(counters.kv_bytes_estimated).toBe(678);
expect(counters.storage_scanned_at).toBeDefined();
});
});
+73
View File
@@ -2,6 +2,8 @@ import { Counters, Env, StatsResponse } from "../types";
import { STATS_KEY } from "../config/constants";
import { logger } from "../lib/logger";
import { listAllFeeds } from "../routes/admin/helpers";
import { getFeedMetadata } from "./storage";
import { getAttachmentBucket } from "./attachments";
const EMPTY_COUNTERS: Counters = {
feeds_created: 0,
@@ -82,5 +84,76 @@ export async function getStats(env: Env): Promise<StatsResponse> {
...counters,
active_feeds: feeds.length,
websub_subscriptions_active: websubCount,
attachments_enabled: !!getAttachmentBucket(env),
};
}
/** Sum the byte size and object count of every attachment stored in R2. */
export async function scanR2Usage(
bucket: R2Bucket,
): Promise<{ bytes: number; count: number }> {
let bytes = 0;
let count = 0;
let cursor: string | undefined;
try {
do {
const listed = await bucket.list({ cursor });
for (const obj of listed.objects) {
bytes += obj.size;
count += 1;
}
cursor = listed.truncated ? listed.cursor : undefined;
} while (cursor);
} catch (error) {
logger.error("Error scanning R2 usage", { error: String(error) });
}
return { bytes, count };
}
/**
* Estimate KV storage used. KV exposes no size API, so we sum the per-email
* sizes already recorded in each feed's metadata — email bodies dominate KV
* usage. Feed config/websub/stats keys are excluded, so this is a lower-bound
* estimate.
*/
export async function scanKvUsage(kv: KVNamespace): Promise<{ bytes: number }> {
let bytes = 0;
try {
const feeds = await listAllFeeds(kv);
for (const feed of feeds) {
const metadata = await getFeedMetadata(kv, feed.id);
if (!metadata) continue;
for (const email of metadata.emails) {
bytes += email.size ?? 0;
}
}
} catch (error) {
logger.error("Error estimating KV usage", { error: String(error) });
}
return { bytes };
}
/**
* Overwrite the storage-usage snapshot fields on the counters singleton.
* Unlike bumpCounters these are set (not incremented). Never throws.
*/
export async function setStorageSnapshot(
kv: KVNamespace,
snapshot: {
attachments_bytes: number;
attachments_count: number;
kv_bytes_estimated: number;
},
): Promise<void> {
try {
const current = await getCounters(kv);
current.attachments_bytes = snapshot.attachments_bytes;
current.attachments_count = snapshot.attachments_count;
current.kv_bytes_estimated = snapshot.kv_bytes_estimated;
current.storage_scanned_at = new Date().toISOString();
if (!current.first_seen) current.first_seen = new Date().toISOString();
await kv.put(STATS_KEY, JSON.stringify(current));
} catch (error) {
logger.error("Error writing storage snapshot", { error: String(error) });
}
}