45 lines · 1.8 KB
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> {