import { DurableObject } from "cloudflare:workers";
import { first } from "./db";
import { inboxReadableSql } from "./repos";
/** Bounds drift from writes that bypass `invalidateUnread`, e.g. repo deletes cascading notifications. */
const CACHE_TTL_MS = 5 * 60 * 1000;
type Cached = { count: number; at: number };
/**
* One per user. Holds the unread-notification count so the header badge on every page view is an
* in-memory read on a per-user object instead of a COUNT on D1. Writers call `invalidate`; a version
* number keeps a count computed before an invalidation from being cached after it.
*/
export class UserInbox extends DurableObject<Env> {
private cached: Cached | null = null;
private version = 0;
async unread(userId: string): Promise<number> {
if (this.cached && Date.now() - this.cached.at < CACHE_TTL_MS) return this.cached.count;
const version = this.version;
const row = await first<{ count: number }>(
`SELECT COUNT(*) AS count FROM notifications n WHERE user_id = ? AND read_at IS NULL AND ${inboxReadableSql("n")}`,
userId,
);
const count = row?.count ?? 0;
if (version === this.version) this.cached = { count, at: Date.now() };
return count;
}
/** Counts one email in the current window; false once `limit` is spent. Storage-backed so restarts keep it. */
async takeEmailBudget(limit: number, windowMs: number): Promise<boolean> {
const window = Math.floor(Date.now() / windowMs) * windowMs;
const current = await this.ctx.storage.get<{ window: number; sent: number }>("email-budget");
const sent = current?.window === window ? current.sent + 1 : 1;
await this.ctx.storage.put("email-budget", { window, sent });
return sent <= limit;
}
async invalidate(): Promise<void> {