Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

4.2 kB Ruw bestand
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.`,
  );
}