1/** 2 * TASK-0104 §4 — storage quota accounting. 3 * 4 * Model (documented decision; the spec's own inline list — "complete +size, 5 * purge/replace-old-versions −size, restore no change" — is the event 6 * counter, and the acceptance test asserts the COLUMN after `complete`, 7 * "not a recompute"): 8 * 9 * `Workspace.usageStorageBytes` = the running total of BYTES COMMITTED to 10 * the files bucket by this workspace's completed uploads (per non-deleted 11 * file, per version — every retained version's bytes count, because a 12 * version's object stays in the bucket until purge / "delete old versions", 13 * spec §1). It is maintained as an EVENT counter, updated transactionally 14 * with the file-row change that moved the bytes: 15 * 16 * complete() + size (bytes land in the bucket at complete) 17 * delete-old-versions() − Σ pruned (objects + rows deleted) 18 * purge() − Σ all (every version object deleted) 19 * restore() ± 0 (bytes were retained while trashed) 20 * 21 * The upload-session pre-check reads the counter O(1) (never a live sum) — 22 * "advisory-fast + authoritative at session time — re-check the sum if the 23 * counter looks stale > 1 GB drift" (spec §Security): the LIVE fast path 24 * trusts the counter; only the DENIAL boundary runs the ledger sum, and only 25 * a stale-LOW counter (sum − counter > 1 GB, e.g. the one-time backfill that 26 * used `Σ File.size` instead of the FileVersion ledger) is corrected to the 27 * sum. A small drift (e.g. retained VIRUS_DETECTED bytes, which have no 28 * `FileVersion` row) keeps the — conservative — event counter. 29 */ 30 31/** 1 GB — the spec's "counter looks stale" drift threshold (spec §4/§Security). */ 32export const STALE_DRIFT_THRESHOLD = 1024 ** 3; 33 34/** 35 * Per-plan storage quota (and max single-file size). The single source of 36 * truth for the plan ceiling — `effectiveStorageQuota` below folds in a 37 * per-workspace `usageLimits.storageQuota` override (min-rule: an admin can 38 * tighten, never raise above the plan). Lives here (the quota module) rather 39 * than in `uploads.service` so the workspace usage endpoint shares the exact 40 * same rule (TASK-0104 §4 — the storage bar must show the real quota). 41 */ 42export const PLAN_USAGE_LIMITS: Record<string, { maxFileSize: number; storageQuota: number }> = { 43 free: { maxFileSize: 50 * 1024 * 1024, storageQuota: 1024 * 1024 * 1024 }, 44 paid: { maxFileSize: 500 * 1024 * 1024, storageQuota: 10 * 1024 * 1024 * 1024 }, 45}; 46 47/** 48 * The workspace's effective storage quota: `min(plan quota, usageLimits 49 * override)` — an admin override can only tighten (a non-positive / absent 50 * override falls back to the plan ceiling). 51 */ 52export function effectiveStorageQuota( 53 billingPlan: string, 54 usageLimits: { storageQuota?: number } | null, 55): number { 56 const plan = PLAN_USAGE_LIMITS[billingPlan] ?? PLAN_USAGE_LIMITS.free!; 57 const override = usageLimits?.storageQuota; 58 return Math.min( 59 plan.storageQuota, 60 typeof override === "number" && override > 0 ? override : Infinity, 61 ); 62} 63 64/** 65 * Structural interface of the three delegates used (testable with a fixture, 66 * the same pattern as `lib/authorize.ts`). A Prisma client — plain or 67 * `$transaction` — satisfies it (the real `*AggregateArgs`/`*UpdateArgs` 68 * parameters are assignable to `object` by contravariance). 69 */ 70export interface QuotaPrisma { 71 fileVersion: { 72 // `any` (not a structural `object` param + narrow return): Prisma's 73 // generated delegate signatures are not assignable to a hand-written 74 // structural interface by variance (the real `*AggregateArgs`/return 75 // generics fight a literal `Promise<{_sum:…}>`). The CALLER's where shape 76 // is still checked by the delegate at runtime + by the real client when 77 // `PrismaService`/`$transaction` is passed (the only production impls) — 78 // the same tradeoff `lib/authorize.ts::AuthorizePrisma` documents. 79 aggregate(args: any): Promise<any>; 80 }; 81 workspace: { 82 findUnique(args: any): Promise<any>; 83 update(args: any): Promise<any>; 84 }; 85} 86 87/** 88 * Transactionally add `deltaBytes` to the workspace's counter (may be 89 * negative for prune/purge). Reads-then-writes inside the caller's 90 * transaction so the row change and the counter are one atomic unit 91 * ("no drift window", spec §Security). Returns the new total. 92 */ 93export async function adjustWorkspaceStorageBytes( 94 prisma: QuotaPrisma, 95 workspaceId: string, 96 deltaBytes: bigint 97): Promise<bigint> { 98 const ws = await prisma.workspace.findUnique({ where: { id: workspaceId } }); 99 const current = ws?.usageStorageBytes ?? 0n; 100 const next = current + deltaBytes; 101 // A counter must never go negative (defensive; a negative would mean we 102 // decremented bytes we never counted — treat the floor as 0 and let the 103 // reconciler repair the drift). 104 const safe = next < 0n ? 0n : next; 105 await prisma.workspace.update({ where: { id: workspaceId }, data: { usageStorageBytes: safe } }); 106 return safe; 107} 108 109export interface QuotaPrismaFull extends QuotaPrisma { 110 file: { 111 findMany(args: any): Promise<any>; 112 }; 113} 114 115/** 116 * Authoritative stored-bytes total for a workspace, computed from the ground 117 * truth (not the counter): 118 * Σ FileVersion.size (non-deleted files) — committed versions 119 * + Σ File.size where status = "SCANNING" — in-flight (a SCANNING 120 * version has no 121 * FileVersion row yet) 122 * VIRUS_DETECTED / FAILED versions have no FileVersion row and are excluded 123 * (their bytes are retained in the bucket but were never counted by the event 124 * counter — see the module note). This is the "sum" the spec's "re-check the 125 * sum if the counter looks stale" refers to. 126 */ 127export async function authoritativeStoredBytes( 128 prisma: QuotaPrismaFull, 129 workspaceId: string 130): Promise<bigint> { 131 const [ledger, scanning] = await Promise.all([ 132 prisma.fileVersion.aggregate({ 133 where: { file: { workspaceId, deletedAt: null } }, 134 _sum: { size: true }, 135 }), 136 prisma.file.findMany({ 137 where: { workspaceId, deletedAt: null, status: "SCANNING" }, 138 select: { id: true, size: true }, 139 }), 140 ]); 141 const scanningTotal = scanning.reduce((s: number, f: { size: number }) => s + f.size, 0); 142 const total = BigInt(ledger._sum.size ?? 0) + BigInt(scanningTotal); 143 return total; 144} 145 146/** 147 * Recompute the counter from the `FileVersion` ledger (Σ size over non-deleted 148 * files) and persist it. Used by the maintenance reconciler (drift repair) — 149 * NOT the live path (the live path is the event counter above; see the module 150 * note on why a naive ledger sum undercounts virus/FAILED bytes). 151 */ 152export async function recomputeWorkspaceStorageBytes( 153 prisma: QuotaPrisma, 154 workspaceId: string 155): Promise<bigint> { 156 const res = await prisma.fileVersion.aggregate({ 157 where: { file: { workspaceId, deletedAt: null } }, 158 _sum: { size: true }, 159 }); 160 const total = BigInt(res._sum.size ?? 0); 161 await prisma.workspace.update({ where: { id: workspaceId }, data: { usageStorageBytes: total } }); 162 return total; 163} 164