#Runtime Parameters
$parameter is configuration that lives in the database instead of the
environment: a named, schema-validated value you can change while the app is
running, from the admin UI or from code, with every change recorded and every
previous value still there to roll back to.
1import { $parameter } from "alepha/api/parameters";
It needs an ORM connection and ships an admin controller, so it lives under
alepha/api/ and is registered as a module:
1import { AlephaApiParameters } from "alepha/api/parameters";2 3alepha.with(AlephaApiParameters);
#Which one do you want
Three things in Alepha look like configuration and are not interchangeable.
| Primitive | Lives in | Changes | Audited | Use it for |
|---|---|---|---|---|
$env |
the environment | at boot, by a deploy | no | secrets, connection strings, per-host wiring |
$atom |
process memory | at runtime, per process | no | in-process state, module options |
$parameter |
the database | at runtime, for every process | yes | numbers a human decides and later regrets |
The dividing line is who changes the value and how often. A database URL is an
$env: it changes when the deployment changes, and nobody edits it at 4pm on a
Friday. A free-shipping threshold is a $parameter: somebody in the business
decides it, changes it without a deploy, and will want to know who set it to
zero.
#Declaring one
1import { z } from "alepha"; 2import { $parameter } from "alepha/api/parameters"; 3 4class Checkout { 5 pricing = $parameter({ 6 name: "checkout.pricing", 7 description: "Thresholds the pricing team owns.", 8 schema: z.object({ 9 freeShippingAbove: z.number(),10 maxDiscountPercent: z.number(),11 }),12 default: { freeShippingAbove: 50, maxDiscountPercent: 30 },13 });14}
schema must be a z.object(). default is what the parameter is worth before
anyone has ever set it, so an app boots and behaves correctly against an empty
parameters table: there is no "unconfigured" state to handle.
name uses dot notation, and the admin UI renders it as a tree, so
checkout.pricing and checkout.limits group under one checkout node. Omit
it and the name is derived as <ClassName>.<propertyKey>, which is fine for one
app and a poor idea the moment you rename the class.
#Reading it
1import { z } from "alepha"; 2import { $parameter } from "alepha/api/parameters"; 3 4class Checkout { 5 pricing = $parameter({ 6 name: "checkout.pricing", 7 schema: z.object({ freeShippingAbove: z.number() }), 8 default: { freeShippingAbove: 50 }, 9 });10 11 async shippingCost(total: number) {12 const { freeShippingAbove } = await this.pricing.get();13 return total >= freeShippingAbove ? 0 : 4.9;14 }15}
get() is async, and it is async for a reason that is not "it hits the
database on every call". The first call loads the row and caches it; later calls
are served from memory. Being async is what lets that first load happen lazily,
which is what makes the primitive work on Cloudflare Workers, where there is no
boot phase in which to preload anything.
Two synchronous accessors exist for code that cannot await:
cachedCurrentContentreturns the cached value, falling back to the default.isUsingDefaulttells you whether anything has ever been stored.
Neither triggers a load. In a request handler, await get().
#Changing it
1import { z } from "alepha"; 2import { $parameter } from "alepha/api/parameters"; 3import type { UserAccount } from "alepha/security"; 4 5class Checkout { 6 pricing = $parameter({ 7 name: "checkout.pricing", 8 schema: z.object({ freeShippingAbove: z.number() }), 9 default: { freeShippingAbove: 50 },10 });11 12 async raiseThreshold(user: UserAccount) {13 await this.pricing.set(14 { freeShippingAbove: 75 },15 {16 user,17 changeDescription: "Q4 margin protection",18 tags: ["pricing"],19 },20 );21 }22}
Every set() writes a new version rather than updating a row. user is what
puts a name on it, and changeDescription is what makes the history readable six
months later. Both are optional and both are the difference between an audit
trail and a list of timestamps.
#Scheduling a change
activationDate in the future stores the version without making it current:
1await this.pricing.set(2 { freeShippingAbove: 0 },3 {4 activationDate: new Date("2026-11-27T00:00:00Z"),5 changeDescription: "Black Friday",6 },7);
There is no stored status field and no job that flips one. A version's status is
derived from its activationDate every time it is queried, so "pending" becomes
"current" because the clock moved, not because a process was running at the
right moment. An app that was switched off over the weekend comes back with the
correct value.
#Reacting to a change
Other processes do not poll. A set() publishes on a topic, and every instance
reloads:
1const unsubscribe = this.pricing.sub((value) => {2 this.log.info("pricing changed", { value });3});
sub() returns its own unsubscribe function. The callback runs on every
instance, so treat it as a cache invalidation signal rather than a place to do
work once.
#History, and the version that was in force
1const history = await this.pricing.getHistory({ limit: 20 });2const v3 = await this.pricing.getVersion(3);3await this.pricing.rollback(3, { user, changeDescription: "revert bad edit" });
rollback() does not delete anything. It copies the target version's content
into a new version at the head, so the mistake and the reversal are both in the
history.
getVersionAt() is the one worth knowing about:
1const rules = await this.pricing.getVersionAt(capture.recordedAt);
Use it when a decision belongs to the time of an event rather than the time
of the read. An offline capture uploaded three days late must be evaluated
against the values that were live when it happened, not today's. It returns
null when the timestamp predates version 1.
#Changing the schema
The schema is hashed and the hash is stored with each version, so the provider knows when the code's shape has moved away from the database's. On the next load it runs a cascade, and takes the first step that produces a value the new schema accepts:
- your
migrate(old)function, if you wrote one - the stored value with unknown keys stripped
- the stored value shallow-merged over
default default
Steps 2 to 4 handle the ordinary cases for free: a field you removed is dropped,
a field you added arrives with its default. Write migrate only when a value has
to be transformed, such as a rename or a unit change:
1import { z } from "alepha"; 2import { $parameter } from "alepha/api/parameters"; 3 4class Checkout { 5 pricing = $parameter({ 6 name: "checkout.pricing", 7 schema: z.object({ freeShippingAboveCents: z.number() }), 8 default: { freeShippingAboveCents: 5000 }, 9 migrate: (old) => ({10 freeShippingAboveCents:11 ((old as { freeShippingAbove?: number }).freeShippingAbove ?? 50) * 100,12 }),13 });14}
A migrate that throws, or that returns something the schema rejects, is
logged and the cascade falls through to step 2. It cannot break a boot, which
also means it can fail quietly: check the logs after deploying one.
The migration itself is written as a new version, with a description saying which step produced it, so a value that silently reset to defaults is visible in the history rather than inferred from behaviour.
#The admin API
AlephaApiParameters registers a controller under /parameters, gated by five
permissions:
| Permission | Grants |
|---|---|
admin:parameter:read |
the tree, the list, one parameter, its history |
admin:parameter:create |
writing a new version |
admin:parameter:rollback |
rolling back to an earlier version |
admin:parameter:activate |
activating a pending version immediately |
admin:parameter:delete |
deleting a parameter and all its versions |
Split deliberately: the people who should be able to read a threshold are not always the people who should be able to change it, and the person who can change it is rarely the person who should be able to delete its history.
#See also
- Configurations for
$envand$atom - Caching for values you want fast rather than governed