refactor: split src into domain / application / infrastructure layers

Replace the history-driven lib/ + utils/ split with DDD layers:
- domain/: aggregate, repositories, value objects, pure parsers/format
- application/: feed-service, email-processor, feed-fetcher, stats
- infrastructure/: logging, auth, KV/R2 adapters, HTTP, framework glue

Pure file relocation; imports updated mechanically. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 00:46:56 +02:00
parent ab1c15e69a
commit 7bf0f71f86
45 changed files with 90 additions and 68 deletions
+148
View File
@@ -0,0 +1,148 @@
import { Counters, Env, StatsResponse } from "../types";
import { logger } from "../infrastructure/logger";
import { FeedRepository } from "../domain/feed-repository";
import { CountersRepository } from "../domain/counters-repository";
import { WebSubSubscriptionRepository } from "../domain/websub-subscription-repository";
import { FeedId } from "../domain/value-objects/feed-id";
import { getAttachmentBucket } from "../infrastructure/attachments";
const EMPTY_COUNTERS: Counters = {
feeds_created: 0,
feeds_deleted: 0,
emails_received: 0,
emails_rejected: 0,
unsubscribes_sent: 0,
};
export async function getCounters(kv: KVNamespace): Promise<Counters> {
try {
const stored = await new CountersRepository(kv).getRaw();
return { ...EMPTY_COUNTERS, ...(stored || {}) };
} catch (error) {
logger.error("Error reading counters", { error: String(error) });
return { ...EMPTY_COUNTERS };
}
}
/**
* Read-modify-write the counters singleton. KV has no atomic increment, so
* concurrent invocations can lose updates — accepted given KV's eventual
* consistency and this app's low volume (see email-processor.ts storeEmail).
* Never throws: counter failures must not break ingestion or admin flows.
*/
export async function bumpCounters(
kv: KVNamespace,
changes: Partial<Omit<Counters, "first_seen">>,
): Promise<void> {
try {
const current = await getCounters(kv);
current.feeds_created += changes.feeds_created ?? 0;
current.feeds_deleted += changes.feeds_deleted ?? 0;
current.emails_received += changes.emails_received ?? 0;
current.emails_rejected += changes.emails_rejected ?? 0;
current.unsubscribes_sent += changes.unsubscribes_sent ?? 0;
if (changes.last_email_at) current.last_email_at = changes.last_email_at;
if (changes.last_feed_created_at)
current.last_feed_created_at = changes.last_feed_created_at;
if (!current.first_seen) current.first_seen = new Date().toISOString();
await new CountersRepository(kv).put(current);
} catch (error) {
logger.error("Error updating counters", { error: String(error) });
}
}
export async function countKeysByPrefix(
kv: KVNamespace,
prefix: string,
): Promise<number> {
return new FeedRepository(kv).countKeysByPrefix(prefix);
}
export async function getStats(env: Env): Promise<StatsResponse> {
const repo = FeedRepository.from(env);
const [counters, feeds, websubCount] = await Promise.all([
getCounters(env.EMAIL_STORAGE),
repo.listFeeds(),
WebSubSubscriptionRepository.from(env).countKeys(),
]);
return {
...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 repo = new FeedRepository(kv);
const feeds = await repo.listFeeds();
for (const feed of feeds) {
const metadata = await repo.getMetadata(FeedId.fromTrusted(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 new CountersRepository(kv).put(current);
} catch (error) {
logger.error("Error writing storage snapshot", { error: String(error) });
}
}