Tier Admin
Manually assign a pricing tier to a tenant — without a billing purchase and without writing the projection by hand. The recipe shows the SystemAdmin-only operator flow plus the subtlety that makes it correct: the grant updates the in-memory resolver cache synchronously, so toggleable features unlock in the same request.
What it shows
Section titled “What it shows”tier-engine:write:set-tenant-tier— a SystemAdmin assigns a tier to any tenant, cross-tenant. Stampssource: "manual"so a future Stripe → tier sync won’t overwrite the grant.tier-engine:query:get-tenant-tier— SystemAdmin reads back which tier a tenant is on plus its source ("manual"vs"billing").tier-engine:query:tier-options— lists the configured tier names so the admin UI doesn’t have to hard-code them.- Cache-sync invariant — the manual grant updates the resolver cache
the same request, not just the projection. A
r.toggleable()feature that depends on the granted tier is reachable immediately after the set call, before any cache refresh, replay, or restart.
Why the cache invariant matters
Section titled “Why the cache invariant matters”set-tenant-tier writes through the event-store executor directly. That
path bypasses the postSave entity-hook the resolver normally uses to
invalidate the cache after a tier-assignment change. Without an explicit
cache update the grant would persist (next request would see it) but
this request still sees the old tier — surprising for an operator who
just clicked “set tier to pro”.
The feature wires onAssigned into createSetTenantTierWrite for
exactly this reason; the integration test below exercises the full flow
end-to-end so the invariant has runnable evidence.
bun test src/__tests__/feature.integration.test.tsSource code
Section titled “Source code”The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):
// Tier Admin Sample//// Shows the SystemAdmin-only operator flow: assigning a tier to *any*// tenant without a billing purchase. The app side is intentionally tiny —// the recipe is the bundled tier-engine itself, configured with the app's// own TierMap. The integration test exercises the cross-tenant grant// (set-tenant-tier), the read-back (get-tenant-tier returning// source:"manual"), and the option-list (tier-options) end-to-end.
import { createTierEngineFeature, type TierMap,} from "@cosmicdrift/kumiko-bundled-features/tier-engine";
// --- App caps ---//// Each app picks its own cap dimensions. Here a tiny example: how many// notes a tenant may keep. The TierMap is generic in this cap-shape so// downstream code stays type-safe end-to-end.
export type AppCaps = { readonly maxNotes: number };
// --- Tier map ---//// "free" + "pro" — the operator picks one of these names when granting// a tier manually. `features` is empty in this sample because the recipe// focuses on the grant flow; a real app would list the toggleable// feature ids that the tier unlocks.
export const appTierMap: TierMap<AppCaps> = { free: { features: [], caps: { maxNotes: 5 } }, pro: { features: [], caps: { maxNotes: 100 } },};
// --- Configured tier-engine ---//// `defaultTier: "free"` means every new tenant starts on free via the// `inTransaction` entity hook the tier-engine registers — no app code// needed. `tierMap` makes `tier-options` return ["free", "pro"] so the// tier-admin screen can populate its picker without hard-coding.
export const tierEngineForApp = createTierEngineFeature<AppCaps>({ defaultTier: "free", tierMap: appTierMap,});📄 On GitHub: samples/recipes/tier-admin/src/feature.ts