Skip to content

feat(12factor-env): fallback-class wrappers + house NODE_ENV semantics (+ graphql-env dedup)#1385

Merged
pyramation merged 2 commits into
mainfrom
feat/12factor-env-fallbacks
Jul 17, 2026
Merged

feat(12factor-env): fallback-class wrappers + house NODE_ENV semantics (+ graphql-env dedup)#1385
pyramation merged 2 commits into
mainfrom
feat/12factor-env-fallbacks

Conversation

@pyramation

@pyramation pyramation commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes 12factor-env express the distinction that actually matters for config — "is there an honest fallback?" — instead of only "optional vs required", and starts consolidating the repeated env-coercion helpers into it. First consumer migrated: graphql-env.

12factor-env: fallback-class wrappers

Three thin wrappers over any envalid validator so the fallback class is legible at the declaration site:

env(process.env, {}, {
  JOBS_SCHEMA:              withDefault(str, 'app_jobs'),        // class 1
  SYNC_GATEWAY_BASE_DOMAIN: devDefault(str, 'sync.localhost'),   // class 2
  K8S_API_URL:              required(url)                        // class 3
});
wrapper unset dev/test unset prod env present
withDefault(v, x) x x env
devDefault(v, x) x throws env
required(v) throws throws env

This is the class that eliminates prod-lie defaults (e.g. an empty-string URL or a localhost base domain silently shipping to production).

Key papercut fixed: NODE_ENV detection

envalid natively treats an unset NODE_ENV as production, so devDefault would throw in ordinary local dev. This normalizes NODE_ENV before validation — without mutating the caller's env — using house semantics mirroring @pgpmjs/env:

getNodeEnv({})                         // 'development'   (unset => dev, NOT prod)
getNodeEnv({ NODE_ENV: 'production' }) // 'production'
getNodeEnv({ NODE_ENV: 'testing' })   // 'test'
getNodeEnv({ GITHUB_ACTIONS: 'true' })// 'test'

Exported as getNodeEnv / isProduction / isTest / isDevelopment, and injected into the input passed to envalid's cleanEnv so its native devDefault/testDefault resolve correctly.

Lenient coercion helpers

boolish accepts true/1/yes case-insensitively (envalid's bool rejects TRUE/yes) and guards the typed-default case. Plus parseEnvBoolean / parseEnvNumber matching the @pgpmjs/env convention — the shared home for the coercion we keep re-implementing.

Consumer: graphql-env

graphql-env had its own copy of the [true,1,yes] boolean coercion. Dropped it and imports parseEnvBoolean from 12factor-env (workspace dep). Behavior unchanged — every call site is guarded by a conditional spread, so the value is never ''/undefined.

-const parseEnvBoolean = (val?: string): boolean | undefined => {
-  if (val === undefined) return undefined;
-  return ['true', '1', 'yes'].includes(val.toLowerCase());
-};
+import { parseEnvBoolean } from '12factor-env';

Notes

  • Pure additions to 12factor-env — existing env(inputEnv, secrets, vars) API, secret-file (_FILE, /run/secrets) behavior, and validator re-exports unchanged.
  • 31 12factor-env tests pass (20 new); graphql-env tests pass.
  • compute-env (in constructive-db) is the next consumer but needs 12factor-env published first; deferred. pgpm reclassification deferred to a later phase.

Link to Devin session: https://app.devin.ai/sessions/b4f03ceb2daf4231b0d30dbee3ae89da
Requested by: @pyramation

…mantics

Add withDefault/devDefault/required wrappers over any envalid validator to
make a var's fallback intent explicit at the declaration site:

  withDefault(v, x)  honest fallback in every environment
  devDefault(v, x)   fallback in dev/test, required (throws) in production
  required(v)        no fallback; throws in every environment when absent

devDefault relies on house NODE_ENV semantics: envalid natively treats an
unset NODE_ENV as production (making devDefault throw in local dev). This
normalizes NODE_ENV before validation without mutating the caller's env:
unset => development, test/testing/GITHUB_ACTIONS => test, production =>
production. Exported as getNodeEnv/isProduction/isTest/isDevelopment.

Also add lenient coercion: boolish validator (accepts TRUE/yes/1 that
envalid bool rejects) plus parseEnvBoolean/parseEnvNumber helpers.
@pyramation pyramation self-assigned this Jul 17, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

graphql-env duplicated the '[true,1,yes]' boolean coercion locally. Drop
the copy and import parseEnvBoolean from 12factor-env (workspace dep), the
canonical home for repeated env-coercion helpers. Behavior is unchanged:
every call site is already guarded by a conditional spread, so the value is
never '' or undefined.
@devin-ai-integration devin-ai-integration Bot changed the title feat(12factor-env): fallback-class wrappers + house NODE_ENV semantics feat(12factor-env): fallback-class wrappers + house NODE_ENV semantics (+ graphql-env dedup) Jul 17, 2026
@pyramation
pyramation merged commit 4c4aa7d into main Jul 17, 2026
38 checks passed
@pyramation
pyramation deleted the feat/12factor-env-fallbacks branch July 17, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant