# 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
import { $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
import { AlephaApiParameters } from "alepha/api/parameters";

alepha.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
import { z } from "alepha";
import { $parameter } from "alepha/api/parameters";

class Checkout {
  pricing = $parameter({
    name: "checkout.pricing",
    description: "Thresholds the pricing team owns.",
    schema: z.object({
      freeShippingAbove: z.number(),
      maxDiscountPercent: z.number(),
    }),
    default: { freeShippingAbove: 50, maxDiscountPercent: 30 },
  });
}
```

`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
import { z } from "alepha";
import { $parameter } from "alepha/api/parameters";

class Checkout {
  pricing = $parameter({
    name: "checkout.pricing",
    schema: z.object({ freeShippingAbove: z.number() }),
    default: { freeShippingAbove: 50 },
  });

  async shippingCost(total: number) {
    const { freeShippingAbove } = await this.pricing.get();
    return total >= freeShippingAbove ? 0 : 4.9;
  }
}
```

`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
import { z } from "alepha";
import { $parameter } from "alepha/api/parameters";
import type { UserAccount } from "alepha/security";

class Checkout {
  pricing = $parameter({
    name: "checkout.pricing",
    schema: z.object({ freeShippingAbove: z.number() }),
    default: { freeShippingAbove: 50 },
  });

  async raiseThreshold(user: UserAccount) {
    await this.pricing.set(
      { freeShippingAbove: 75 },
      {
        user,
        changeDescription: "Q4 margin protection",
        tags: ["pricing"],
      },
    );
  }
}
```

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
await this.pricing.set(
  { freeShippingAbove: 0 },
  {
    activationDate: new Date("2026-11-27T00:00:00Z"),
    changeDescription: "Black Friday",
  },
);
```

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:

```mermaid
sequenceDiagram
  participant A as Instance A
  participant DB as Database
  participant T as Topic
  participant B as Instance B
  A->>DB: insert version N+1
  A->>A: refresh cache, notify sub()
  A->>T: publish parameter:sync
  T->>B: parameter:sync
  B->>DB: load current and next
  B->>B: refresh cache, notify sub()
```

```typescript
const unsubscribe = this.pricing.sub((value) => {
  this.log.info("pricing changed", { value });
});
```

`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
const history = await this.pricing.getHistory({ limit: 20 });
const v3 = await this.pricing.getVersion(3);
await 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
const row = await this.pricing.getVersionAt(capture.recordedAt);
const 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
import { z } from "alepha";
import { $parameter } from "alepha/api/parameters";

class Checkout {
  pricing = $parameter({
    name: "checkout.pricing",
    schema: z.object({ freeShippingAboveCents: z.number() }),
    default: { freeShippingAboveCents: 5000 },
    migrate: (old) => ({
      freeShippingAboveCents:
        ((old as { freeShippingAbove?: number }).freeShippingAbove ?? 50) * 100,
    }),
  });
}
```

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](https://latest.alepha.dev/docs/packages-alepha-api-audits.md)): 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](https://latest.alepha.dev/docs/packages-alepha-ui-admin.md) 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](/admin/parameters-dark.png)

## See also

- [Configurations](https://latest.alepha.dev/docs/guides-core-configurations.md) for `$env` and `$atom`
- [Caching](https://latest.alepha.dev/docs/guides-persistence-caching.md) for values you want fast rather
  than governed
- [`alepha/api/parameters`](https://latest.alepha.dev/docs/packages-alepha-api-parameters.md) for the full
  API reference
