Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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.`,
);
}