PrimeBrickPrimeBrick
  • Docs
  • Contact
  • MIT License
  • Documentation
  • MCP Server
  • API Catalog
  • Services
  • Libraries
PrimeBrickPrimeBrick

© 2026 PrimeBrick. MIT License. v3.11.2

github
DAL Library
SDK Library
    Developer Guide
      OverviewGetting StartedAnatomy of a MicroserviceAuthenticationClient Shield & Rotating SecretsHTTP ServerRPC RouterEntity RouterOpenAPI GenerationDocs PackConfig tables & ConfigLoaderDatabase MigrationsTranslationsCurrency UtilitiesNATS™ ClientService RegistrationSSE StandardPresenceExt-JSONRedis cache layerTelemetry & LoggingAPI Reference
Decimal Library
powered by Zudoku
Developer Guide

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)

ConcernEnv varsConfig table
Update at runtimeRestart requiredPUT → cache reload, no restart
Per-row i18n label / descriptionImpossiblelabel_key / description_key columns
Type-safe consumptionManual parseInt everywheretype column → ConfigLoader<TResult> auto-coerces
Admin UINoneReusable ConfigTable FE component renders inputs from type
Audit trailNoneAuditable rows (created_at/by, updated_at/by, version, soft-delete)
ValidationApp-level onlyDB 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
CREATE TABLE "<schema>"."config" ( "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "uuid" uuid DEFAULT gen_random_uuid() NOT NULL, "key" varchar(100) NOT NULL, -- unique setting key "value" text, -- raw TEXT; null = "not set" "type" varchar(50) NOT NULL, -- see type vocabulary "enum_values" text, -- JSON array, only for type='enum' "label_key" varchar(100), -- i18n key for the setting title "description_key" varchar(100), -- i18n key for the explanatory text "created_at" timestamptz DEFAULT now(), "created_by" text, "updated_at" timestamptz DEFAULT now(), "updated_by" text, "version" integer DEFAULT 1, "deleted_at" timestamptz, "deleted_by" text, CONSTRAINT "config_key_uq" UNIQUE ("key") );

Column responsibilities

ColumnPurpose
keyStable, snake_case identifier (e.g. oidc_issuer_url, enable_mfa). Never renamed after release.
valueRaw TEXT. null means "the row exists but the value is not set". Missing key means "the row does not exist".
typeOne of the values in the type vocabulary below. Drives SDK coercion and FE widget selection.
enum_valuesJSON array of allowed strings, only populated when type = 'enum'.
label_keyi18n key (e.g. config.auth.oidc_issuer_url.label) resolved by the FE to a translated title.
description_keyi18n 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:

typeSDK coercionFE widgetNotes
stringstring as-isInput type=textSingle-line text.
textstring as-isTextareaMulti-line text.
booleanvalue === "true"SwitchDB stores only "true" / "false".
bigintBigInt(value)Input type=numberInteger values backed by JS BigInt. Empty/null stays null.
numberNumber(value)Input type=numberDecimal/float values backed by JS Number. Empty/null stays null.
moneyNumber(value)Input type=number + currency symbolNumeric amount with currency metadata in type_config.
badgestring, validated against type_config.valuesComboSelecttype_config is a JSON object with values.
single_selectstring, validated against API or values_sourceComboSelect (single)type_config has api_url or values_source, value_field, label_field. Was list.
multi_selectcomma-separated stringComboSelect (multi)Same type_config as single_select. Value stored as CSV (e.g. "EUR,USD,GBP").
urlstring as-isInput type=urlValidated at write/upsert path.
secretstring as-isPassword (masked)Never returned in clear text to the FE; BE masks in list/get, accepts updates only.
jsonJSON.parse(value)TextareaFE 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
{"currency":"EUR"}

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:

typetype_config shapeExample
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
{ "validation": { "required": true, "unsigned": true, "rules": { "min": { "value": 0, "error_label_key": "config.x.errors.min" }, "max": { "value": 999, "error_label_key": "config.x.errors.max" } } } }
FieldApplies toDescription
requiredAll typesIf true, empty/null values are rejected. Secrets with empty value are skipped (empty = "leave unchanged").
required_error_label_keyAll typesOptional i18n key for the required error. Falls back to validation.required.
unsignedbigint, number, moneyIf 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.minbigint/number/money (numeric value); string/text/secret/url (length)Minimum value or minimum length.
rules.maxbigint/number/money (numeric value); string/text/secret/url (length)Maximum value or maximum length.
rules.urlurl, stringAllowed URL protocols (e.g. ["http", "https"]).
rules.emailstring, textEmail format validation.
rules.regexstring, text, secretRegex 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.min is set, the minimum defaults to 0. An explicit rules.min with a value greater than 0 takes precedence.
  • FE input widget: the numeric input switches to type="text" with inputmode="numeric" (or decimal for number/money) and filters invalid characters in real-time as the user types. The spinner min attribute is set to 0 (or the explicit rules.min value) so the down-arrow stops at the minimum.

Example type_config for an unsigned bigint with min=10:

Code
{ "validation": { "unsigned": true, "required": true, "rules": { "min": { "value": 10, "error_label_key": "config.x.errors.min" } } } }

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 → undefined in the typed result; null rows → null.
  • Write path: FE and BE upsert validate per type / enum_values before 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
import type { IConfigEntity, ConfigRepositoryPort } from "@primebrick/sdk"; interface IConfigEntity { key: string; value: string | null; type: ConfigType; // "string" | "boolean" | "bigint" | "number" | "money" | ... type_config?: string | null; // JSON metadata (currency, badge values, list API config) label_key?: string; description_key?: string; } interface ConfigRepositoryPort { findAll(): Promise<IConfigEntity[]>; }

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
import { ConfigLoader } from "@primebrick/sdk"; import type { AuthConfig } from "@primebrick/sdk"; const loader = new ConfigLoader(new ConfigRepositoryAdapter(dal)); // each US's src/adapters/config-repository-adapter.ts // Once at startup: const config = await loader.load(); // config is now AuthConfig with boolean / number / enum fields filled in // Hot path (zero DB hits): const enableMfa = loader.require("enable_mfa"); // boolean const ttl = loader.require("mfa_challenge_token_ttl_seconds"); // number const issuerUrl = loader.get("oidc_issuer_url"); // string | null // After a config update via PUT: loader.invalidate(); await loader.load();

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
const config = await loader.load(); validateAuthConfig(config); // throws on missing mandatory fields

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:

  1. Update the DB row (DAL write).
  2. If the entity is @Cached(), withCache invalidates the Redis prefix automatically on write.
  3. 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
import { ConfigEntityBase } from "@primebrick/dal-pg"; import { Entity } from "@primebrick/dal-pg"; @Entity("config", "auth") // schema-qualified: auth.config export class ConfigEntryEntity extends ConfigEntityBase {}

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
GET /api/v1/entities/config_entries/meta → field schema for dynamic forms GET /api/v1/entities/config_entries/list → all rows (secrets masked) GET /api/v1/entities/config_entries/:uuid → single row (secrets masked) PUT /api/v1/entities/config_entries/:uuid → update value (validates type)

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 → Input
    • text / json → Textarea
    • bigint / number → Input type="number"
    • money → Input type="number" + currency symbol (from type_config.currency)
    • boolean → Switch
    • badge → ComboSelect (options from type_config.values)
    • single_select → ComboSelect single mode (options from type_config.api_url or type_config.values_source)
    • multi_select → ComboSelect multi 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

  1. Insert a row into the config table with key, value, type, enum_values (if enum), label_key, description_key.
  2. Add the field to the consumer's TResult interface (e.g. AuthConfig) with the matching name and TS type.
  3. Add the translation keys (label_key, description_key) to the FE i18n message files.
  4. 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

ModuleTableTResultLocation
BE authpublic.configAuthConfigprimebrick-be-v3/src/modules/auth/
emailsenderemailsender.configEmailSenderConfigprimebrick-us-v3/emailsender/

New modules should mirror one of these two references.

Last modified on October 28, 2026
Docs PackDatabase Migrations
On this page
  • Why a dictionary table (not env vars)
  • Standard table shape
    • Column responsibilities
  • Type vocabulary (single source of truth)
    • bigint vs number
    • money type
    • type_config column
    • Validation rules (type_config.validation)
  • Data-quality rules
  • SDK: IConfigEntity and ConfigRepositoryPort
  • SDK: ConfigLoader<TResult> — typed, cached, auto-coerced
    • Why TResult is consumer-defined
    • Mandatory-field validation
    • Caching
  • DAL: ConfigEntityBase
  • HTTP API (entity CRUD)
    • Secret masking
  • Frontend: reusable ConfigTable component
  • End-to-end data flow
  • Adding a new config key
  • Reference implementations
JSON
JSON
JSON
TypeScript
TypeScript
TypeScript
TypeScript