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

© 2026 PrimeBrick. MIT License. v3.8.0

github
DAL Library
SDK Library
    OverviewGetting StartedAuthenticationExt-JSONRedis cache layerConfig tables & ConfigLoaderNATS™ ClientService RegistrationHTTP ServerSSE StandardPresenceAPI Reference
powered by Zudoku
SDK Library

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".
integerparseInt(value, 10)Input type=numberEmpty/null stays null.
numberparseFloat(value)Input type=numberEmpty/null stays null.
enumstring, validated against enum_valuesComboSelectenum_values is a JSON string array.
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.

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 → 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" | "integer" | ... enum_values?: string | null; // JSON array string, only for type='enum' 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<AuthConfig>(new BeAuthConfigRepositoryAdapter(dal)); // 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
    • integer / number → Input type="number"
    • boolean → Switch
    • enum → ComboSelect (options from enum_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

  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 July 26, 2026
Redis cache layerNATS™ Client
On this page
  • Why a dictionary table (not env vars)
  • Standard table shape
    • Column responsibilities
  • Type vocabulary (single source of truth)
  • 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
TypeScript
TypeScript
TypeScript
TypeScript