@speles7172/config-client
v0.1.1
Published
Application configuration over Postgres — typed, declared settings an administrator can edit, through an executor you supply.
Readme
@speles7172/config-client
Application configuration over Postgres. One idea: a key.
Every setting an application is configured by — payment windows, feature flags, scheduling thresholds, which user approves what — is a key, a declared type, and a value stored as text, in one table. So "what is this application configured as" is one query and one object, rather than a hunt through environment variables, a constants file and a feature-flag service.
npm install @speles7172/config-clientRequires Node 22+ and Postgres. No database dependency of its own — you supply
the executor. No AWS SDK, no pg, nothing.
Declaring what exists
import { createConfigStore, defineConfig } from '@speles7172/config-client';
const schema = defineConfig([
{
key: 'PAYMENT_TERMS_DAYS',
type: 'number',
default: '30',
category: 'payment',
label: 'Payment window',
description: 'Days until an invoice is due.',
min: 1,
max: 365,
},
{ key: 'FEATURE_GATES', type: 'list', category: 'features', label: 'Feature flags' },
{ key: 'APPROVER', type: 'reference', refType: 'user', category: 'payment' },
{ key: 'STAGE', type: 'text', default: 'prod', readOnly: true },
]);
const config = createConfigStore({ execute: pool.query.bind(pool), schema });A declaration is optional — an undeclared key still stores, reads and edits, so a project's first settings need no ceremony. What declaring buys is the three things a bare key/value table cannot know: the type to validate against, the default to fall back to, and the words to put on the form.
It is also what replaces a seed migration per setting. The implementation this
generalises shipped fifteen of them, each an INSERT … ON CONFLICT DO NOTHING
whose only content was a default and a description — so every new setting meant
a migration against a production database, and changing a default meant another
one that had to avoid the rows an administrator had already edited. Here the
default lives in code beside the reader.
Reading
const settings = await config.snapshot();
settings.number('PAYMENT_TERMS_DAYS', 30);
settings.boolean('DIGEST_ENABLED');
settings.enabled('FEATURE_GATES', 'invoice_scan');
settings.string('APPROVER');One query for the whole table, cached for five minutes, decoded on the way out. Reads never throw: a key that is missing, empty, or holds something that no longer parses gives the fallback at the call site — a malformed row must not take out a request path that only wanted a threshold.
snapshot() is also what @speles7172/config-console's <ConfigProvider>
gives the frontend, decoded by these very functions, so a flag that is on for a
Lambda is on for the page.
Writing
await config.setValue('PAYMENT_TERMS_DAYS', '45', { id: user.id });
await config.create({ key: 'DIGEST_HOUR', type: 'number', value: '7' });
await config.update('DIGEST_HOUR', { category: 'notifications' });
await config.remove('DIGEST_HOUR');setValue is a genuine upsert. The reference's equivalent was an UPDATE …
WHERE variable_code = $1, so a save against a key whose seed migration had
never run affected zero rows, returned no error, and reported success — the
setting simply did not change, and the only way to find out was to reload the
page.
Every write validates against the declaration first: the type, a closed set of
choices, min/max, required, and readOnly. A key is fixed once it
exists — see Traps.
The table
await pool.query(configTableSql()); // or paste it into a migrationNothing is provisioned for you. ensureConfigTable() exists for a project with
no migration runner and says as much: a CREATE TABLE on the request path is
one statement between a deploy and an outage.
The table name is configurable (table: 'ops.settings'). There is deliberately
no CHECK on value_type and no scope column — see Traps.
Recording who changed what
const config = createConfigStore({
execute,
schema,
onChange: (change) =>
audit.record({
entityType: 'app_config',
entityId: change.id,
entityName: change.key,
action: change.action,
changes: [{ field: 'value', from: change.before, to: change.after }],
}),
});The seam to @speles7172/audit-client without a dependency
on it. A configuration change is exactly the kind of thing somebody asks about
six months later, and this package has no business deciding where the answer
lives.
The sink is awaited, and a throw fails the write — a change that happened and was never recorded is indistinguishable from a change nobody made.
Moving a configuration between environments
const file = await config.exportAll(); // staging
const outcome = await production.importAll(file);
// { created: 4, updated: 11, skipped: [{ index: 7, key: 'STAGE', reason: 'Read-only…' }] }Nothing is dropped silently. The reference filtered its input and answered
{created, updated}, so a file with one typo'd type imported cleanly, reported
success, and left that setting at its old value.
Value types
| Type | Stored as | Edited by |
|---|---|---|
| text, multiline | the text | a box |
| number | '12.5' | a number box, with the declared bounds |
| boolean | 'true' / 'false' | a checkbox |
| date | '2026-08-20' | a date picker |
| datetime | an ISO instant | a local date-and-time picker |
| json | JSON | a textarea |
| list | a JSON array of {label, key, value} | <KeyValueList> |
| reference | the record's id | a searchable select over your own records |
There is no user type: a reference to a record is reference plus a refType
the application names, so a project whose settings point at warehouses needs no
change here. There is no secret type either, deliberately — see below.
Entry points
| | |
|---|---|
| @speles7172/config-client | Node. The store, the SQL, the DDL, the cache. |
| @speles7172/config-client/core | Dependency-free. Types, decoding, declarations, snapshots, import parsing. What a browser imports. |
Both are published as ESM and CommonJS.
Traps
There is no secret type, and adding one would be worse than useless. A
masked field in an admin table is not encryption: the value is still plaintext
in a column, in every backup, and in the export file. A type that looks like
somewhere to put an API key invites exactly that. Put secrets in a secret
manager and configure the name of the secret here.
A key is fixed once the row exists. Renaming does not move the readers:
every snapshot.number('OLD_KEY') in the codebase quietly starts returning its
fallback, and the setting somebody thought they had relabelled has in fact been
switched off. ConfigPatch cannot express a rename; delete and re-create says
the same thing out loud.
No CHECK constraint on value_type, and that is deliberate. The reference
put its type list in one, and 065_settings_list_type.sql exists solely to add
the word list to it — a migration against production to teach the schema a
word TypeScript already knew. The vocabulary is validated in TypeScript instead.
No scope column. These are settings for the application, not per-tenant
overrides. A scope column looks cheap and is not: every read becomes a
resolution order, every write has to say which layer it lands on, and a value
that silently resolved to the global row is indistinguishable from one that was
set. A project needing per-tenant settings wants a second table.
The cache is per process, and list/get never read through it. A value
written by one Lambda instance is stale in every other for up to the TTL. That
is a fine trade for a feature flag and a wrong one for the admin page that just
saved it, which is why only snapshot() is cached.
An empty value means "use the default", not "set to empty". That is the property that lets a default change in code and take effect without a migration — and it is why the console's value box is pre-filled with what is stored, never with the default. A box pre-filled with the default looks identical to one somebody set, and saving it turns "follows the default" into "pinned to today's default".
Dates come back as strings, never as Date. new Date('2026-08-20') is
midnight UTC, which is the 19th in every timezone west of Greenwich. A
Date handed out here would make a date-only setting mean a different day
depending on where the reader runs.
License
MIT
