import { all } from "./db"; import { accountDb, repoDb } from "./scoped-db"; import { backfillDone, backfillRepos } from "./backfill"; /** * Attachments live in each repository's RepoState but quotas are per account. Each account's AccountState keeps * `attachment_bytes`: per repository, the bytes it owns (quota owner) and uploaded. Entries are recomputed from the * repository's rows (never incremented), so a retry or the backfill can't double-count. */ export const ATTACHMENT_BYTES = "attachment-bytes"; /** Recomputes `accountIds`' entries for one repository from its attachment rows. */ export async function syncAttachmentBytes(repoId: string, accountIds: Iterable): Promise { const db = repoDb(repoId); await Promise.all( [...new Set(accountIds)].map(async (accountId) => { const row = await db.first<{ owned: number | null; uploaded: number | null }>( `SELECT (SELECT SUM(size) FROM attachments WHERE owner_id = ?1) AS owned, (SELECT SUM(size) FROM attachments WHERE uploader_id = ?1) AS uploaded`, accountId, ); const owned = row?.owned ?? 0; const uploaded = row?.uploaded ?? 0; const account = accountDb(accountId); if (owned === 0 && uploaded === 0) { await account.run("DELETE FROM attachment_bytes WHERE repo_id = ?", repoId); return; } await account.run( `INSERT INTO attachment_bytes (repo_id, owned, uploaded) VALUES (?, ?, ?) ON CONFLICT (repo_id) DO UPDATE SET owned = excluded.owned, uploaded = excluded.uploaded`, repoId, owned, uploaded, ); }), ); } /** Before a repository's object is destroyed: its bytes stop counting for everyone who had them. */ export async function forgetRepoAttachmentBytes(repoId: string): Promise { const rows = await repoDb(repoId).all<{ id: string }>( "SELECT owner_id AS id FROM attachments UNION SELECT uploader_id FROM attachments", ); await Promise.all(rows.map((row) => accountDb(row.id).run("DELETE FROM attachment_bytes WHERE repo_id = ?", repoId))); } /** Bytes counting against `ownerId` as quota owner and against `uploaderId` as uploader. */ export async function attachmentUsage( ownerId: string, uploaderId: string, ): Promise<{ owner_total: number; uploader_total: number }> { if (await backfillDone(ATTACHMENT_BYTES)) { const [owned, uploaded] = await Promise.all([ accountDb(ownerId).first<{ total: number | null }>("SELECT SUM(owned) AS total FROM attachment_bytes"), accountDb(uploaderId).first<{ total: number | null }>("SELECT SUM(uploaded) AS total FROM attachment_bytes"), ]); return { owner_total: owned?.total ?? 0, uploader_total: uploaded?.total ?? 0 }; } const repos = await all<{ id: string }>("SELECT id FROM repos"); const totals = await Promise.all( repos.map((repo) => repoDb(repo.id).first<{ owner_total: number | null; uploader_total: number | null }>( `SELECT (SELECT SUM(size) FROM attachments WHERE owner_id = ?1) AS owner_total, (SELECT SUM(size) FROM attachments WHERE uploader_id = ?2) AS uploader_total`, ownerId, uploaderId, ), ), ); return totals.reduce<{ owner_total: number; uploader_total: number }>( (sum, row) => ({ owner_total: sum.owner_total + (row?.owner_total ?? 0), uploader_total: sum.uploader_total + (row?.uploader_total ?? 0), }), { owner_total: 0, uploader_total: 0 }, ); } /** Cron step: fills `attachment_bytes` for attachments uploaded before it existed. */ export async function backfillAttachmentBytes(): Promise { await backfillRepos(ATTACHMENT_BYTES, async (repoId) => { const rows = await repoDb(repoId).all<{ id: string }>( "SELECT owner_id AS id FROM attachments UNION SELECT uploader_id FROM attachments", ); await syncAttachmentBytes( repoId, rows.map((row) => row.id), ); }); }