import { DurableObject } from "cloudflare:workers"; import { ScopedStore } from "../scoped-db/store"; import { ACCOUNT_SCHEMA } from "../scoped-db/schema.gen"; import { ACCOUNT_SCHEMA_EXTRA } from "../scoped-db/schema-extra"; import { loadActiveUserById } from "../user-row"; import "./tasks"; import { armsHousekeeping, armsPrecise, forgetSwept, gcDueNow, mark, marksOf, nextDue, runAccountTasks, sweepDue, type LastRuns, } from "./housekeeping"; import { configurePerf, withJobPerf } from "../perf"; import type { UserRow } from "~/lib/types"; import type { ScopedQueryResult, ScopedRunResult, ScopedSqlHost, ScopedStatement, ScopedValue, } from "../scoped-db/types"; const USER_CACHE_MS = 15_000; const SCOPE_KEY = "scope-id"; /** With no known scope, tasks wait this long before the next attempt instead of re-running at the floor interval. */ const RETRY_MS = 5 * 60 * 1000; const LAST_SWEEP_KEY = "housekeeping-swept-at"; const RUNS_KEY = "housekeeping-runs"; /** * One per account (user or organization): sessions, credentials, notifications and their settings, org * membership, owner secrets and variables, projects, billing and usage (docs/state-layout.md). Callers reach * it through `accountDb(accountId)`. */ export class AccountState extends DurableObject implements ScopedSqlHost { private readonly store = new ScopedStore(this.ctx.storage, [...ACCOUNT_SCHEMA, ...ACCOUNT_SCHEMA_EXTRA]); async sqlAll(sql: string, params: ScopedValue[]): Promise[]> { await this.store.prepare(); return this.store.all(sql, params); } async sqlQuery(sql: string, params: ScopedValue[]): Promise { await this.store.prepare(); return this.store.query(sql, params); } async sqlRun(sql: string, params: ScopedValue[], scopeId?: string): Promise { await this.store.prepare(); const result = this.store.run(sql, params); await this.afterWrite([sql], scopeId); return result; } async sqlBatch(statements: ScopedStatement[], scopeId?: string): Promise { await this.store.prepare(); const results = this.store.batch(statements); await this.afterWrite( statements.map((statement) => statement.sql), scopeId, ); return results; } private scopeId: string | null = null; /** The account this object belongs to; remembered from callers so the alarm can reach D1 and send email. */ private async ownScope(scopeId?: string): Promise { if (scopeId && scopeId !== this.scopeId) { this.scopeId = scopeId; await this.ctx.storage.put(SCOPE_KEY, scopeId); } this.scopeId ??= (await this.ctx.storage.get(SCOPE_KEY)) ?? null; return this.scopeId; } /** Marks debounced work the write made due and arms the alarm for it (housekeeping.ts). */ private async afterWrite(sqls: string[], scopeId?: string): Promise { if (!sqls.some(armsHousekeeping)) return; const marks = [...new Set(sqls.flatMap(marksOf))]; if (marks.length > 0) mark(this.store.sql, marks, Date.now()); await this.arm(scopeId, sqls.some(armsPrecise)); } private lastRuns: LastRuns | null = null; private async runs(): Promise { this.lastRuns ??= (await this.ctx.storage.get(RUNS_KEY)) ?? {}; return this.lastRuns; } /** * Sets the alarm no later than the next expiry or task; a later alarm is pulled in, an earlier one kept. A newly * expiring row is only ever due after the pending alarm, which recomputes when it fires, so only deadline work * (or no alarm at all) needs the due queries. */ private async arm(scopeId?: string, precise = true): Promise { await this.ownScope(scopeId); const current = await this.ctx.storage.getAlarm(); if (current !== null && !precise) return; const lastSweep = (await this.ctx.storage.get(LAST_SWEEP_KEY)) ?? null; const due = nextDue(this.store.sql, Date.now(), lastSweep, await this.runs()); if (due === null) return; if (current === null || current > due) await this.ctx.storage.setAlarm(due); } /** For the one-time backfill of accounts whose rows predate the alarm. */ async armHousekeeping(scopeId: string): Promise { await this.store.prepare(); await this.arm(scopeId); } /** Collects expired rows (at most hourly), runs due tasks (digests, holds, gauges, alerts), then re-arms. */ override async alarm(): Promise { configurePerf((this.env as { PERF_SAMPLE_RATE?: string }).PERF_SAMPLE_RATE); await withJobPerf("alarm.account", () => this.housekeep()); } private async housekeep(): Promise { await this.store.prepare(); const now = Date.now(); const lastSweep = (await this.ctx.storage.get(LAST_SWEEP_KEY)) ?? null; if (gcDueNow(this.store.sql, now, lastSweep)) { // Stale locators only point at rows that are gone (lookups then miss), so their cleanup is best effort. await forgetSwept(sweepDue(this.store.sql, now)).catch((error: unknown) => console.warn("Locator cleanup after housekeeping failed", error), ); await this.ctx.storage.put(LAST_SWEEP_KEY, now); } const runs = await this.runs(); const scopeId = await this.ownScope(); // Without a known scope (no caller ever named it) tasks can't reach other objects; wait instead of spinning. if (scopeId) await runAccountTasks(scopeId, this.store.sql, now, runs); else runs["scope:failed"] = now; await this.ctx.storage.put(RUNS_KEY, runs); const due = nextDue(this.store.sql, Date.now(), (await this.ctx.storage.get(LAST_SWEEP_KEY)) ?? null, runs); if (due !== null) await this.ctx.storage.setAlarm(scopeId ? due : Math.max(due, Date.now() + RETRY_MS)); } private user: { row: UserRow | null; at: number } | null = null; /** The directory row behind every signed-in request, kept for a few seconds so page views skip D1. */ async cachedUser(userId: string): Promise { if (this.user && Date.now() - this.user.at < USER_CACHE_MS) return this.user.row; const row = await loadActiveUserById(userId); this.user = { row, at: Date.now() }; return row; } async forgetUser(): Promise { this.user = null; } /** The account is deleted. */ async destroy(): Promise { this.user = null; await this.store.wipe(); } }