alepha@docs:~/framework/docs/guides/features$
cat 3-runtime-parameters.md | pretty
9 min read
Last commit:

#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.

typescript
1import { $parameter } from "alepha/api/parameters";

It needs an ORM connection (it stores every version in a parameters table) and ships an admin controller, so it lives under alepha/api/ as the AlephaApiParameters module. You rarely register that module by hand: the first $parameter a service declares pulls it in. Register it explicitly only when you want the admin API in an app that declares no parameter of its own:

typescript
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

typescript
 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

typescript
 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. On a long-lived Node process the provider does preload: on ready it loads (and, if the schema changed, migrates) every registered parameter, so the first request does not pay for it.

Two synchronous accessors exist for code that cannot await:

  • cachedCurrentContent returns the cached value, falling back to the default.
  • isUsingDefault is true while no stored value is in the cache: either nothing was ever stored, or nothing was loaded yet.

Neither triggers a load. In a request handler, await get().

#Changing it

typescript
 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.

Immediate writes refresh the writing process's cache before returning, whether they come from set(), the admin API, or ParameterProvider.save(). Subscribers receive the validated value when it changes.

#Scheduling a change

activationDate in the future stores the version without making it current:

typescript
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.

Each version's status, as the history and the admin UI show it:

Status Meaning
current the latest version whose activationDate has passed
next the earliest version whose activationDate is in future
future any later scheduled version
expired every other past version

A running process holds the next version in memory too, and the first get() after its date switches to it without a database round trip. The switch calls that process's sub() callbacks when the value changed, and publishes nothing: each instance switches on its own get().

#Reacting to a change

Other processes do not poll. An immediate set() publishes on a topic (parameter:sync), and every other instance reloads the row:

Sequence diagramInstance AInstance ADatabaseDatabaseTopicTopicInstance BInstance Binsert version N+1refresh cache, notify sub()publish parameter:syncparameter:syncload current and nextrefresh cache, notify sub()
typescript
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 whose cached value a save, a reload or a scheduled switch actually changed, so treat it as a cache invalidation signal rather than a place to do work once.

#How long a change takes to land everywhere

The topic rides the queue provider, which is in-memory by default. On a single long-lived Node process that is instant and this section does not apply. On a serverless runtime it does not apply either, for the opposite reason: many isolates serve the same app, and a set() handled by one of them never reaches the others.

So the cache also expires. get() re-reads the row once the cached value is older than PARAMETERS_CACHE_TTL_MS, which defaults to 30 seconds on serverless and to 0 (never revalidate) elsewhere. A flip therefore reaches every isolate within about half a minute, and an operator who reloads the page one second after saving may still see the old behaviour.

Say so wherever an admin flips something that a human will immediately go and test. A switch that appears not to have worked gets flipped again.

#History, and the version that was in force

typescript
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. delete() is the one call that does: it removes every version of the parameter, which then reads its default again. reload() forces a re-read from the database without waiting for the cache to expire.

getHistory(), getVersion() and getVersionAt() return stored rows, not bare values: the value is in content, next to version, activationDate, changeDescription, tags, creatorName and previousContent.

getVersionAt() is the one worth knowing about:

typescript
1const row = await this.pricing.getVersionAt(capture.recordedAt);2const rules = row?.content ?? this.pricing.options.default;

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, and it is never cached: every call is a query.

#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:

  1. your migrate(old) function, if you wrote one
  2. the stored value with unknown keys stripped
  3. the stored value shallow-merged over default
  4. 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:

typescript
 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 AdminParameterController, whose actions live under /api/parameters (the $action prefix), 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.

Route Permission Does
GET /api/parameters/tree read the dot-notation tree, with each node's origin
GET /api/parameters read every stored name
GET /api/parameters/:name read current, next, default, JSON schema
GET /api/parameters/:name/history read every version, with its status
GET /api/parameters/:name/versions/:version read one version
POST /api/parameters/:name create a new version, immediate or scheduled
POST /api/parameters/:name/rollback rollback a new version copying targetVersion
POST /api/parameters/:name/activate activate a new, immediate version copying a pending one
DELETE /api/parameters/:name delete every version of one parameter
POST /api/parameters/delete delete every version of several parameters

A few behaviours worth knowing:

  • Reading a registered parameter that has no row yet seeds version 1 from its default ("Auto-seeded from compiled defaults"), so the admin always has a concrete row to edit and roll back to.
  • The tree marks each name registered (declared in code, never saved), orphan (stored, but no $parameter declares it any more, typically after a rename) or both.
  • Writes from the admin are validated against the registered schema, and the version records the user's username (or email) as its creator.
  • Admin writes are audited when you opt in with alepha.with(ParameterAudits) (from alepha/api/parameters, it pulls in the audits module): every create, rollback, activate and delete is then logged as a parameter audit event. Without it, auditing is skipped silently. Writes made from code with set() are not audited; their version history is the record.

The @alepha/ui admin ships the screen for this API: a tree on the left, the current value as a form generated from the schema, and the version history.

The parameters admin screen

#See also