import { describeLimit, planOf, type PlanId } from '@wellformed/shared'; import { HttpError } from '../errors.js'; import { config } from '../config.js'; import type { Db } from '../db/driver.js'; import { countRows as countTableRows, type MetaSnapshot } from '../db/meta.js'; /** * What a plan actually stops you doing. * * Two numbers, enforced in two places, and that is deliberately the whole of * it. Every additional metered dimension is a support question ("why can't I * add this?"), a billing edge case and a migration path forever — and the * pricing page has to explain it. * * Going over never locks a workspace: reading, editing, exporting and deleting * all keep working. The only thing that stops is *growing*. A customer who * lapses must always be able to get their data out, which means the failure * mode of not paying can never be a wall in front of the export button. * * The limit is a **ceiling you are stopped at, not a gate you are measured * against**. A workspace with room left may take a write of any size, even one * that lands well past the limit; the next write is the one that is refused. * * That asymmetry is on purpose, and it is really about the import. Refusing a * 4,780-row spreadsheet because the plan allows 1,000 means the one thing a * new customer came here to do — see whether their real file works in this — * fails at the exact moment they would have found out. Letting it land costs a * few thousand rows of a SQLite file and buys the only demonstration that * matters; the wall arrives afterwards, in front of a workspace that is * already full of their own data rather than an empty one. */ export function overLimit(message: string): HttpError { // 402 is the one status that says exactly this and nothing else. return new HttpError(402, 'plan_limit', message); } /** * The ceiling that actually applies — which is not always the plan's. * * An unmetered install has none at all (see `config.metered`): the workspace * still records a plan, and the operator can still move it, but the number * stops being a wall. Every enforcement below reads through here, so that * "this install does not meter" is one fact in one place rather than three * conditions that can drift apart. */ function ceiling(plan: PlanId): { rowLimit: number | null; tableLimit: number | null } { if (!config.metered) return { rowLimit: null, tableLimit: null }; const { rowLimit, tableLimit } = planOf(plan); return { rowLimit, tableLimit }; } /** Rows across every table in the workspace — the only number the plan counts. */ export function countRows(db: Db, meta: MetaSnapshot): number { return meta.tableOrder.reduce((total, name) => total + countTableRows(db, name), 0); } /** * Throw when the workspace has no room left at all. * * How many rows are about to be written is deliberately not part of the * question: while there is a row of headroom the write goes through whole, * however big it is. See the note above for why. */ export function assertRoomForRows(plan: PlanId, db: Db, meta: MetaSnapshot): void { const limit = ceiling(plan).rowLimit; if (limit === null) return; const used = countRows(db, meta); if (used < limit) return; throw overLimit( used > limit ? `This workspace holds ${used.toLocaleString('en-US')} rows, past the ${describeLimit(limit, 'rows')} its plan allows. Upgrade to add more — everything already here still works, and you can export it whenever you like.` : `This workspace is at the ${describeLimit(limit, 'rows')} its plan allows. Upgrade to keep adding rows — everything already here still works.`, ); } /** Whether a workspace has grown past what its plan allows. */ export function isOverRowLimit(plan: PlanId, db: Db, meta: MetaSnapshot): boolean { const limit = ceiling(plan).rowLimit; return limit !== null && countRows(db, meta) > limit; } export function assertRoomForTable(plan: PlanId, meta: MetaSnapshot, adding = 1): void { const limit = ceiling(plan).tableLimit; if (limit === null) return; const used = meta.tableOrder.length; if (used + adding <= limit) return; throw overLimit( `This workspace is at the ${describeLimit(limit, 'tables')} its plan allows. Upgrade to add more.`, ); }