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:
1 parent
7226e718f7
commit
f150d40c45
19 files changed
+387
-23
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user