Config tables & ConfigLoader
Primebrick modules store their runtime configuration in dictionary-style
config tables: one row per setting, keyed by a unique key, with the raw
value stored as TEXT and a type column that drives automatic type
coercion and Frontend widget selection.
This is the single standard pattern for module configuration across the Backend (BE) and every microservice (US). Any new module that needs configuration follows this pattern and gets caching, typed access, i18n labels, and a reusable admin UI for free.
Why a dictionary table (not env vars)
| Concern | Env vars | Config table |
|---|---|---|
| Update at runtime | Restart required | PUT → cache reload, no restart |
| Per-row i18n label / description | Impossible | label_key / description_key columns |
| Type-safe consumption | Manual parseInt everywhere | type column → ConfigLoader<TResult> auto-coerces |
| Admin UI | None | Reusable ConfigTable FE component renders inputs from type |
| Audit trail | None | Auditable rows (created_at/by, updated_at/by, version, soft-delete) |
| Validation | App-level only | DB enum_values + BE upsert validation + FE widget validation |
Env vars are still used for the bootstrap connection (DATABASE_URL,
NODE_ENV) and for secrets that must never touch the DB. Everything else
lives in a config table.
Standard table shape
Every config table — regardless of which module owns it — uses the same
column set, defined by ConfigEntityBase in @primebrick/dal-pg:
Code
Column responsibilities
| Column | Purpose |
|---|---|
key | Stable, snake_case identifier (e.g. oidc_issuer_url, enable_mfa). Never renamed after release. |
value | Raw TEXT. null means "the row exists but the value is not set". Missing key means "the row does not exist". |
type | One of the values in the type vocabulary below. Drives SDK coercion and FE widget selection. |
enum_values | JSON array of allowed strings, only populated when type = 'enum'. |
label_key | i18n key (e.g. config.auth.oidc_issuer_url.label) resolved by the FE to a translated title. |
description_key | i18n key (e.g. config.auth.oidc_issuer_url.description) resolved by the FE to a translated help text. |
Type vocabulary (single source of truth)
The type column is read by both the SDK (for coercion) and the FE (for
widget selection). The vocabulary is fixed and shared:
type | SDK coercion | FE widget | Notes |
|---|---|---|---|
string | string as-is | Input type=text | Single-line text. |
text | string as-is | Textarea | Multi-line text. |
boolean | value === "true" | Switch | DB stores only "true" / "false". |
integer | parseInt(value, 10) | Input type=number | Empty/null stays null. |
number | parseFloat(value) | Input type=number | Empty/null stays null. |
enum | string, validated against enum_values | ComboSelect | enum_values is a JSON string array. |
url | string as-is | Input type=url | Validated at write/upsert path. |
secret | string as-is | Password (masked) | Never returned in clear text to the FE; BE masks in list/get, accepts updates only. |
json | JSON.parse(value) | Textarea | FE stringifies on save. |
Adding a new type requires coordinated changes in the SDK (coerceConfigValue)
and the FE (ConfigTable widget switch). Do not add types casually.
Data-quality rules
- Read path (SDK
ConfigLoader): only real type conversions (string → boolean/integer/number/json). No lowercasing, trimming, or fallback defaults. Missing keys →undefinedin the typed result;nullrows →null. - Write path: FE and BE upsert validate per
type/enum_valuesbefore writing. Secrets are write-only from the FE perspective. - No fake defaults: if a mandatory config is missing,
load()throws a clear error. The system fails loud rather than running on invented values.
SDK: IConfigEntity and ConfigRepositoryPort
The SDK defines the standard row shape and the port that consumers implement:
Code
The SDK is DB-agnostic. The consumer (BE or microservice) provides an adapter that uses its own DAL to read the config rows.
SDK: ConfigLoader<TResult> — typed, cached, auto-coerced
The generic ConfigLoader<TResult> reads all rows once at startup, coerces
each value according to its type column, and builds a typed object whose
field names match the DB keys exactly. TResult is the consumer's typed
shape (e.g. AuthConfig for the BE auth module, EmailSenderConfig for the
emailsender microservice).
Code
Why TResult is consumer-defined
The SDK does not know which keys a module will store. The consumer declares
the typed interface (AuthConfig, EmailSenderConfig, ...) with field
names that match the DB keys. ConfigLoader.load() iterates rows and
assigns result[row.key] = coerceConfigValue(row), so the typed object is
populated automatically as long as the field names line up.
Mandatory-field validation
ConfigLoader.load() does NOT enforce mandatory fields — it only coerces.
Mandatory checks (e.g. "auth_mode is required", "at least one auth method
must be enabled") are consumer-specific and run in a validate() step
after load():
Code
Caching
ConfigLoader keeps an in-memory Map populated by load(). The hot path
(get, require) never touches the DB. After a config update, the
consumer must call invalidate() then load() again so the running
service picks up the new value without a restart.
This in-memory cache is complementary to the SDK's Redis cache layer
(@Cached / withCache). The Redis layer caches single-row DAL reads
(findByUUID, findById); ConfigLoader caches the full typed
dictionary. Config updates must invalidate both:
- Update the DB row (DAL write).
- If the entity is
@Cached(),withCacheinvalidates the Redis prefix automatically on write. - Call
configLoader.invalidate()+configLoader.load()to refresh the in-memory typed cache.
DAL: ConfigEntityBase
@primebrick/dal-pg provides a base entity class so every config table has
the same column set without copy-paste:
Code
For the monolithic BE, the auth module uses @Entity("config", "public").
For microservices, each service uses its own schema
(@Entity("config", "emailsender")). The base class guarantees the column
shape is identical everywhere.
HTTP API (entity CRUD)
Every config table is exposed to the Frontend via the standard entity CRUD path pattern (see the API path conventions rule for microservices):
Code
The meta endpoint returns the field schema consumed by the FE
ConfigTable component for dynamic rendering. The list endpoint returns
rows with type, enum_values, label_key, description_key so the FE
can pick the right widget and translate labels without any hard-coding.
Secret masking
Rows with type = 'secret' are never returned in clear text. The BE
returns null (or a fixed mask like "••••") for the value field in
list and get responses. The FE renders a Password input with a
placeholder and only sends a value when the user explicitly types a new
one. An empty PUT body for a secret means "leave unchanged".
Frontend: reusable ConfigTable component
The FE has a single reusable component that renders any config table as a two-column layout:
- Left column: translated label (
$t(entry.label_key)) and translated description ($t(entry.description_key)). - Right column: a widget chosen by
entry.type:string/url→Inputtext/json→Textareainteger/number→Input type="number"boolean→Switchenum→ComboSelect(options fromenum_values)secret→Password(masked)
The component coerces the user-entered value back to a string before
calling onSave ('true' / 'false' for booleans, String(value) for
numbers, JSON.stringify for json). The BE upsert validates the string
against the row's type / enum_values before writing.
This means a new module's config page is created by adding a route that
fetches /api/v1/entities/config_entries/list and renders
<ConfigTable entries={...} onSave={...} /> — no per-module form code.
End-to-end data flow
Adding a new config key
- Insert a row into the config table with
key,value,type,enum_values(ifenum),label_key,description_key. - Add the field to the consumer's
TResultinterface (e.g.AuthConfig) with the matching name and TS type. - Add the translation keys (
label_key,description_key) to the FE i18n message files. - If the key is mandatory, add a check in the consumer's
validate()function.
No SDK, DAL, or FE component changes are needed — the existing
ConfigLoader<TResult> and ConfigTable handle it automatically.
Reference implementations
| Module | Table | TResult | Location |
|---|---|---|---|
| BE auth | public.config | AuthConfig | primebrick-be-v3/src/modules/auth/ |
| emailsender | emailsender.config | EmailSenderConfig | primebrick-us-v3/emailsender/ |
New modules should mirror one of these two references.