Skip to content
64 linesCodeBlameRaw
1/**
2 * What a workspace without the g1t plan may keep in files put in its pages
3 * and artifacts (images, attachments): `FREE_DOC_FILES_BYTES` (1 GiB, a
4 * wrangler var; `0` is no cap). Past it, a new upload is refused with a
5 * message saying so; nothing already kept is touched. Billing is asked
6 * only once the workspace is past the cap, and when it does not answer the
7 * upload goes ahead.
8 */
9import type { ServiceBinding } from "@g1t/contracts";
10
11export const DEFAULT_FREE_DOC_FILES_BYTES = 1024 * 1024 * 1024;
12
13export type QuotaEnv = { BILLING?: ServiceBinding; FREE_DOC_FILES_BYTES?: string };
14
15/** The cap from its var: the default when unset or not a whole number; 0 is none. */
16export function freeFilesCap(raw: string | undefined): number {
17 const value = Number((raw ?? "").trim());
18 return raw !== undefined && raw.trim() !== "" && Number.isInteger(value) && value >= 0 ? value : DEFAULT_FREE_DOC_FILES_BYTES;
19}
20
21/** `1 GB`, `1.5 GB`, `300 MB`. */
22export function shownBytes(bytes: number): string {
23 const gb = 1024 * 1024 * 1024;
24 if (bytes >= gb) {
25 const tenths = Math.ceil((bytes * 10) / gb);
26 return tenths % 10 === 0 ? `${tenths / 10} GB` : `${(tenths / 10).toFixed(1)} GB`;
27 }
28 return `${Math.ceil(bytes / (1024 * 1024))} MB`;
29}
30
31/** What a free workspace is told when `adding` would take it past `cap`; null while it fits. */
32export function filesRefusal(held: number, adding: number, cap: number): string | null {
33 if (cap <= 0 || held + adding <= cap) return null;
34 return `This workspace's files hold ${shownBytes(held)} of the ${shownBytes(cap)} a workspace on the free plan keeps, so this file is not kept. Delete files you no longer need, or start the g1t plan, which has no such cap.`;
35}
36
37/** Why an upload of `adding` bytes to `workspace` is refused, or null. */
38export async function uploadRefused(
39 db: D1Database,
40 env: QuotaEnv,
41 workspace: { id: string; slug: string },
42 adding: number,
43): Promise<string | null> {
44 const cap = freeFilesCap(env.FREE_DOC_FILES_BYTES);
45 if (cap <= 0) return null;
46 const row = await db
47 .prepare(
48 `SELECT (SELECT COALESCE(SUM(bytes), 0) FROM files WHERE workspace_id = ?1)
49 + (SELECT COALESCE(SUM(bytes), 0) FROM folio_files WHERE workspace_id = ?1) AS bytes`,
50 )
51 .bind(workspace.id)
52 .first<{ bytes: number }>();
53 const why = filesRefusal(row?.bytes ?? 0, adding, cap);
54 if (!why || !env.BILLING) return null;
55 try {
56 // Loaded here, so the rules above test under Node without the contracts' runtime.
57 const { billingClient } = await import("@g1t/contracts");
58 const free = await billingClient(env.BILLING).freeWorkspaces([workspace.slug]);
59 return free.map((slug) => slug.toLowerCase()).includes(workspace.slug.toLowerCase()) ? why : null;
60 } catch (error) {
61 console.error("artifacts: billing did not say whether the workspace is free", workspace.slug, error);
62 return null;
63 }
64}