npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-client

Requires 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 migration

Nothing 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