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". |
bigint | BigInt(value) | Input type=number | Integer values backed by JS BigInt. Empty/null stays null. |
number | Number(value) | Input type=number | Decimal/float values backed by JS Number. Empty/null stays null. |
money | Number(value) | Input type=number + currency symbol | Numeric amount with currency metadata in type_config. |
badge | string, validated against type_config.values | ComboSelect | type_config is a JSON object with values. |
single_select | string, validated against API or values_source | ComboSelect (single) | type_config has api_url or values_source, value_field, label_field. Was list. |
multi_select | comma-separated string | ComboSelect (multi) | Same type_config as single_select. Value stored as CSV (e.g. "EUR,USD,GBP"). |
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. |
bigint vs number
The bigint type is for integer values backed by JavaScript BigInt.
This avoids precision loss for large integers (e.g. IDs, counters, TTLs in
seconds). The number type is for decimal/float values backed by
JavaScript Number.
The conceptual type "integer" was renamed to "bigint" to make the
backing JS type explicit. Existing rows are migrated by a fire-and-forget
SQL patch.
money type
The money type is a primitive numeric display type, not a composite
wire-level type. It behaves like number for coercion (the amount is a
JS Number), but the FE renders a currency symbol next to the input.
Currency metadata is stored in type_config as JSON:
Code
There is no Money class, no Money interface, and no __money ext-JSON
tag. The amount is a primitive number; the currency is metadata.
type_config column
The type_config column stores JSON metadata for the config type:
type | type_config shape | Example |
|---|---|---|
money | {"currency":"<ISO 4217 code>"} | {"currency":"EUR"} |
badge | {"values":{"<value>":{"label_key":"...","color":"..."}}} | {"values":{"active":{"label_key":"badge.active","color":"green"}}} |
single_select / multi_select | {"api_url":"...","value_field":"...","label_field":"..."} or {"values_source":"currencies","value_field":"...","label_field":"..."} | {"api_url":"/api/v1/...","value_field":"uuid","label_field":"name"} |
string/text/boolean/bigint/number/url/secret/json | {"validation":{...}} (optional) | {"validation":{"required":true,"rules":{}}} |
Adding a new type requires coordinated changes in the SDK (coerceConfigValue)
and the FE (ConfigValueInput widget switch). Do not add types casually.
Validation rules (type_config.validation)
All config types support an optional validation object inside type_config.
The SDK validator (validateConfigValue) and the FE Zod builder
(buildConfigValueSchema) read the same shape, so validation is enforced
consistently on both sides.
Code
| Field | Applies to | Description |
|---|---|---|
required | All types | If true, empty/null values are rejected. Secrets with empty value are skipped (empty = "leave unchanged"). |
required_error_label_key | All types | Optional i18n key for the required error. Falls back to validation.required. |
unsigned | bigint, number, money | If true, the value is treated as unsigned: sign characters (-, +) are rejected, and the effective minimum defaults to 0 when no explicit rules.min is set. Absent or false → signed (default, backward compatible). Only unsigned: true activates unsigned behavior. |
rules.min | bigint/number/money (numeric value); string/text/secret/url (length) | Minimum value or minimum length. |
rules.max | bigint/number/money (numeric value); string/text/secret/url (length) | Maximum value or maximum length. |
rules.url | url, string | Allowed URL protocols (e.g. ["http", "https"]). |
rules.email | string, text | Email format validation. |
rules.regex | string, text, secret | Regex pattern validation. |
Unsigned numeric values
Setting "unsigned": true in type_config.validation makes the SDK and FE
enforce unsigned behavior for bigint, number, and money types:
- Sign characters rejected:
-and+cannot be entered or submitted. - Default minimum is 0: when no explicit
rules.minis set, the minimum defaults to0. An explicitrules.minwith a value greater than0takes precedence. - FE input widget: the numeric input switches to
type="text"withinputmode="numeric"(ordecimalfornumber/money) and filters invalid characters in real-time as the user types. The spinnerminattribute is set to0(or the explicitrules.minvalue) so the down-arrow stops at the minimum.
Example type_config for an unsigned bigint with min=10:
Code
When unsigned is absent or false, the value is signed (default):
negative values are allowed, and no default minimum is applied.
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→Textareabigint/number→Input type="number"money→Input type="number"+ currency symbol (fromtype_config.currency)boolean→Switchbadge→ComboSelect(options fromtype_config.values)single_select→ComboSelectsingle mode (options fromtype_config.api_urlortype_config.values_source)multi_select→ComboSelectmulti mode (same options source; value stored as CSV)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.