|
| 1 | +// planLimits — the dashboard's single, registry-shaped mirror of the per-tier |
| 2 | +// numeric caps the Overview tiles bind to. |
| 3 | +// |
| 4 | +// WHY THIS FILE EXISTS (rule 18 — registry-iterating, not hand-typed-at-call-site): |
| 5 | +// Before this, the Overview "connection limit" and "storage" tiles derived |
| 6 | +// their numbers from per-RESOURCE fields (`connections_limit` / `storage_limit_bytes`) |
| 7 | +// summed across the user's live resources. That produced two confirmed bugs on |
| 8 | +// real dashboards: |
| 9 | +// |
| 10 | +// 1. CONNECTION LIMIT showed "∞ unlimited" for a Pro user. The summing logic |
| 11 | +// flipped the whole tile to ∞ the moment ANY resource carried |
| 12 | +// connections_limit < 0 — and queue/redis/storage/webhook resources are |
| 13 | +// legitimately -1 (connection caps don't apply to them). So a Pro user |
| 14 | +// (real cap: 20 Postgres connections) with a single Redis saw ∞. |
| 15 | +// |
| 16 | +// 2. STORAGE denominator showed a conflated SUM of every per-service cap |
| 17 | +// (e.g. 50 GB object + 10 GB pg + 5 GB mongo + 10 GB vector + queue … |
| 18 | +// ≈ 81.3 GiB) presented under one "STORAGE" label. Pro's object-storage |
| 19 | +// cap is 50 GB; the tile must reflect object storage specifically, not a |
| 20 | +// sum across unlike services. |
| 21 | +// |
| 22 | +// The honest fix is to bind each tile to the TIER's published cap, not to a |
| 23 | +// derived per-resource sum. The source of truth is api/plans.yaml. The |
| 24 | +// `PLAN_LIMITS` table below mirrors it; the matching test |
| 25 | +// (planLimits.test.ts) iterates EVERY tier in the `Tier` union so a future |
| 26 | +// tier (or a renamed one) can't silently fall through to a wrong number. |
| 27 | +// |
| 28 | +// Connection semantics: only the connection-BEARING services (postgres, |
| 29 | +// mongodb, vector) have a finite per-tier connection cap. redis / queue / |
| 30 | +// storage / webhook do not take SQL-style connections — their per-resource |
| 31 | +// connections_limit is -1 by design and must NOT be read as "the tier is |
| 32 | +// unlimited". The connection tile therefore shows the connection-bearing cap |
| 33 | +// (postgres == mongodb == vector on every tier today) and is only "∞" when |
| 34 | +// that cap is itself -1 in plans.yaml (no tier is, post strict-80% redesign). |
| 35 | + |
| 36 | +import type { Tier } from '../api' |
| 37 | + |
| 38 | +const MB_PER_GB = 1024 |
| 39 | + |
| 40 | +export interface PlanLimits { |
| 41 | + /** Per-connection-bearing-service connection cap (postgres/mongodb/vector). |
| 42 | + * -1 means unlimited. plans.yaml: postgres_connections / mongodb_connections / |
| 43 | + * vector_connections — equal on every tier today. */ |
| 44 | + connections: number |
| 45 | + /** Object-storage cap in MB. plans.yaml: storage_storage_mb. -1 = unlimited |
| 46 | + * (no tier today). This is the OBJECT-STORE cap only — never a sum across |
| 47 | + * postgres / mongodb / vector / queue. */ |
| 48 | + objectStorageMB: number |
| 49 | +} |
| 50 | + |
| 51 | +// PLAN_LIMITS — mirror of api/plans.yaml. Keep in lock-step with that file |
| 52 | +// (rule 22: a tier/limit change touches plans.yaml AND this mirror). Every |
| 53 | +// member of the `Tier` union MUST have a row — planLimits.test.ts fails if one |
| 54 | +// is missing, so a new tier can't ship a tile bound to the fallback. |
| 55 | +// |
| 56 | +// connections column source (plans.yaml, verified 2026-06-11 @ origin/master): |
| 57 | +// anonymous/free postgres_connections=2 storage_storage_mb=10 |
| 58 | +// hobby postgres_connections=8 storage_storage_mb=512 |
| 59 | +// hobby_plus postgres_connections=8 storage_storage_mb=5120 |
| 60 | +// pro postgres_connections=20 storage_storage_mb=51200 (50 GB) |
| 61 | +// growth postgres_connections=20 storage_storage_mb=153600 (150 GB) |
| 62 | +// team postgres_connections=100 storage_storage_mb=307200 (300 GB) |
| 63 | +export const PLAN_LIMITS: Record<Tier, PlanLimits> = { |
| 64 | + anonymous: { connections: 2, objectStorageMB: 10 }, |
| 65 | + free: { connections: 2, objectStorageMB: 10 }, |
| 66 | + hobby: { connections: 8, objectStorageMB: 512 }, |
| 67 | + hobby_plus: { connections: 8, objectStorageMB: 5120 }, |
| 68 | + pro: { connections: 20, objectStorageMB: 51200 }, |
| 69 | + growth: { connections: 20, objectStorageMB: 153600 }, |
| 70 | + team: { connections: 100, objectStorageMB: 307200 }, |
| 71 | +} |
| 72 | + |
| 73 | +// Fallback used only when the live tier string is somehow outside the union |
| 74 | +// (defensive — TS guarantees the union, but the wire could in theory send a |
| 75 | +// future tier the build doesn't know yet). Free is the safest assumption: it |
| 76 | +// understates rather than overstates the user's ceiling. |
| 77 | +const FALLBACK: PlanLimits = PLAN_LIMITS.free |
| 78 | + |
| 79 | +export function planLimitsFor(tier: Tier | string | undefined | null): PlanLimits { |
| 80 | + if (tier && tier in PLAN_LIMITS) return PLAN_LIMITS[tier as Tier] |
| 81 | + return FALLBACK |
| 82 | +} |
| 83 | + |
| 84 | +/** The connection-bearing connection cap for a tier. -1 → unlimited. */ |
| 85 | +export function connectionLimitFor(tier: Tier | string | undefined | null): number { |
| 86 | + return planLimitsFor(tier).connections |
| 87 | +} |
| 88 | + |
| 89 | +/** The object-storage cap for a tier, in MB. -1 → unlimited. */ |
| 90 | +export function objectStorageLimitMBFor(tier: Tier | string | undefined | null): number { |
| 91 | + return planLimitsFor(tier).objectStorageMB |
| 92 | +} |
| 93 | + |
| 94 | +/** Object-storage cap as a GB number (decimal-GB to match how plans.yaml / |
| 95 | + * the pricing page talk about "50 GB"). -1 stays -1 (unlimited). */ |
| 96 | +export function objectStorageLimitGBFor(tier: Tier | string | undefined | null): number { |
| 97 | + const mb = objectStorageLimitMBFor(tier) |
| 98 | + return mb < 0 ? -1 : mb / MB_PER_GB |
| 99 | +} |
0 commit comments