/** * TASK-0104 §4 — storage quota accounting. * * Model (documented decision; the spec's own inline list — "complete +size, * purge/replace-old-versions −size, restore no change" — is the event * counter, and the acceptance test asserts the COLUMN after `complete`, * "not a recompute"): * * `Workspace.usageStorageBytes` = the running total of BYTES COMMITTED to * the files bucket by this workspace's completed uploads (per non-deleted * file, per version — every retained version's bytes count, because a * version's object stays in the bucket until purge / "delete old versions", * spec §1). It is maintained as an EVENT counter, updated transactionally * with the file-row change that moved the bytes: * * complete() + size (bytes land in the bucket at complete) * delete-old-versions() − Σ pruned (objects + rows deleted) * purge() − Σ all (every version object deleted) * restore() ± 0 (bytes were retained while trashed) * * The upload-session pre-check reads the counter O(1) (never a live sum) — * "advisory-fast + authoritative at session time — re-check the sum if the * counter looks stale > 1 GB drift" (spec §Security): the LIVE fast path * trusts the counter; only the DENIAL boundary runs the ledger sum, and only * a stale-LOW counter (sum − counter > 1 GB, e.g. the one-time backfill that * used `Σ File.size` instead of the FileVersion ledger) is corrected to the * sum. A small drift (e.g. retained VIRUS_DETECTED bytes, which have no * `FileVersion` row) keeps the — conservative — event counter. */ /** 1 GB — the spec's "counter looks stale" drift threshold (spec §4/§Security). */ export const STALE_DRIFT_THRESHOLD = 1024 ** 3; /** * Per-plan storage quota (and max single-file size). The single source of * truth for the plan ceiling — `effectiveStorageQuota` below folds in a * per-workspace `usageLimits.storageQuota` override (min-rule: an admin can * tighten, never raise above the plan). Lives here (the quota module) rather * than in `uploads.service` so the workspace usage endpoint shares the exact * same rule (TASK-0104 §4 — the storage bar must show the real quota). */ export const PLAN_USAGE_LIMITS: Record = { free: { maxFileSize: 50 * 1024 * 1024, storageQuota: 1024 * 1024 * 1024 }, paid: { maxFileSize: 500 * 1024 * 1024, storageQuota: 10 * 1024 * 1024 * 1024 }, }; /** * The workspace's effective storage quota: `min(plan quota, usageLimits * override)` — an admin override can only tighten (a non-positive / absent * override falls back to the plan ceiling). */ export function effectiveStorageQuota( billingPlan: string, usageLimits: { storageQuota?: number } | null, ): number { const plan = PLAN_USAGE_LIMITS[billingPlan] ?? PLAN_USAGE_LIMITS.free!; const override = usageLimits?.storageQuota; return Math.min( plan.storageQuota, typeof override === "number" && override > 0 ? override : Infinity, ); } /** * Structural interface of the three delegates used (testable with a fixture, * the same pattern as `lib/authorize.ts`). A Prisma client — plain or * `$transaction` — satisfies it (the real `*AggregateArgs`/`*UpdateArgs` * parameters are assignable to `object` by contravariance). */ export interface QuotaPrisma { fileVersion: { // `any` (not a structural `object` param + narrow return): Prisma's // generated delegate signatures are not assignable to a hand-written // structural interface by variance (the real `*AggregateArgs`/return // generics fight a literal `Promise<{_sum:…}>`). The CALLER's where shape // is still checked by the delegate at runtime + by the real client when // `PrismaService`/`$transaction` is passed (the only production impls) — // the same tradeoff `lib/authorize.ts::AuthorizePrisma` documents. aggregate(args: any): Promise; }; workspace: { findUnique(args: any): Promise; update(args: any): Promise; }; } /** * Transactionally add `deltaBytes` to the workspace's counter (may be * negative for prune/purge). Reads-then-writes inside the caller's * transaction so the row change and the counter are one atomic unit * ("no drift window", spec §Security). Returns the new total. */ export async function adjustWorkspaceStorageBytes( prisma: QuotaPrisma, workspaceId: string, deltaBytes: bigint ): Promise { const ws = await prisma.workspace.findUnique({ where: { id: workspaceId } }); const current = ws?.usageStorageBytes ?? 0n; const next = current + deltaBytes; // A counter must never go negative (defensive; a negative would mean we // decremented bytes we never counted — treat the floor as 0 and let the // reconciler repair the drift). const safe = next < 0n ? 0n : next; await prisma.workspace.update({ where: { id: workspaceId }, data: { usageStorageBytes: safe } }); return safe; } export interface QuotaPrismaFull extends QuotaPrisma { file: { findMany(args: any): Promise; }; } /** * Authoritative stored-bytes total for a workspace, computed from the ground * truth (not the counter): * Σ FileVersion.size (non-deleted files) — committed versions * + Σ File.size where status = "SCANNING" — in-flight (a SCANNING * version has no * FileVersion row yet) * VIRUS_DETECTED / FAILED versions have no FileVersion row and are excluded * (their bytes are retained in the bucket but were never counted by the event * counter — see the module note). This is the "sum" the spec's "re-check the * sum if the counter looks stale" refers to. */ export async function authoritativeStoredBytes( prisma: QuotaPrismaFull, workspaceId: string ): Promise { const [ledger, scanning] = await Promise.all([ prisma.fileVersion.aggregate({ where: { file: { workspaceId, deletedAt: null } }, _sum: { size: true }, }), prisma.file.findMany({ where: { workspaceId, deletedAt: null, status: "SCANNING" }, select: { id: true, size: true }, }), ]); const scanningTotal = scanning.reduce((s: number, f: { size: number }) => s + f.size, 0); const total = BigInt(ledger._sum.size ?? 0) + BigInt(scanningTotal); return total; } /** * Recompute the counter from the `FileVersion` ledger (Σ size over non-deleted * files) and persist it. Used by the maintenance reconciler (drift repair) — * NOT the live path (the live path is the event counter above; see the module * note on why a naive ledger sum undercounts virus/FAILED bytes). */ export async function recomputeWorkspaceStorageBytes( prisma: QuotaPrisma, workspaceId: string ): Promise { const res = await prisma.fileVersion.aggregate({ where: { file: { workspaceId, deletedAt: null } }, _sum: { size: true }, }); const total = BigInt(res._sum.size ?? 0); await prisma.workspace.update({ where: { id: workspaceId }, data: { usageStorageBytes: total } }); return total; }