Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphql/env/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
"12factor-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@pgpmjs/env": "workspace:^",
"deepmerge": "^4.3.1"
Expand Down
10 changes: 1 addition & 9 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import { ConstructiveOptions } from '@constructive-io/graphql-types';

/**
* Parse GraphQL-related environment variables.
* These are the env vars that Constructive packages need but pgpm doesn't.
*/
const parseEnvBoolean = (val?: string): boolean | undefined => {
if (val === undefined) return undefined;
return ['true', '1', 'yes'].includes(val.toLowerCase());
};
import { parseEnvBoolean } from '12factor-env';

/**
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
Expand Down
65 changes: 65 additions & 0 deletions packages/12factor-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,71 @@ console.log(config.DATABASE_URL); // Validated string
console.log(config.PORT); // Validated number
```

## Fallback classes: `withDefault` / `devDefault` / `required`

The real question for any config value is not "optional vs required" but
**"is there an honest fallback?"**. These thin wrappers over any envalid
validator make that intent explicit at the declaration site:

```ts
import { env, str, url, withDefault, devDefault, required } from '12factor-env';

const config = env(process.env, {}, {
// Class 1 — honest fallback in every environment (ports, timeouts, flags)
JOBS_SCHEMA: withDefault(str, 'app_jobs'),

// Class 2 — fallback in dev/test, but REQUIRED (throws) in production.
// A localhost base domain is fine locally, a bug in prod.
SYNC_GATEWAY_BASE_DOMAIN: devDefault(str, 'sync.localhost'),

// Class 3 — no honest fallback; throws in every environment when absent.
K8S_API_URL: required(url)
});
```

| Wrapper | unset in dev/test | unset in production | env value present |
|---------|-------------------|---------------------|-------------------|
| `withDefault(v, x)` | uses `x` | uses `x` | uses env value |
| `devDefault(v, x)` | uses `x` | **throws** | uses env value |
| `required(v)` | **throws** | **throws** | uses env value |

### House `NODE_ENV` semantics

`devDefault` is enforced in production. Crucially, envalid natively treats an
**unset** `NODE_ENV` as production — which would make `devDefault` throw during
ordinary local development where nobody sets `NODE_ENV`. This library normalizes
`NODE_ENV` first (without mutating the caller's environment) so:

- explicit `production` → `production`
- explicit `test`/`testing`, or `GITHUB_ACTIONS=true` → `test`
- anything else, **including unset** → `development`

The same logic is exported as helpers:

```ts
import { getNodeEnv, isProduction, isTest, isDevelopment } from '12factor-env';

getNodeEnv(); // 'development' | 'production' | 'test'
isProduction(); // boolean
```

### Lenient coercion

Envalid's built-in `bool` rejects values like `TRUE` or `yes`. `boolish` accepts
`true`/`1`/`yes` case-insensitively (and is safe to combine with a boolean
default). The underlying coercion helpers are also exported:

```ts
import { env, boolish, withDefault, parseEnvBoolean, parseEnvNumber } from '12factor-env';

const config = env(process.env, {}, {
FEATURE_ENABLED: withDefault(boolish, false) // accepts TRUE/yes/1
});

parseEnvBoolean('YES'); // true
parseEnvNumber('42'); // 42
```

## Secret File Support

This library supports reading secrets from files, which is useful for Docker secrets and Kubernetes secrets that are mounted as files.
Expand Down
178 changes: 175 additions & 3 deletions packages/12factor-env/__tests__/env.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import { writeFileSync, unlinkSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { mkdirSync, rmSync,unlinkSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';

import { env, str, port, bool, host } from '../src';
import {
bool,
boolish,
devDefault,
env,
getNodeEnv,
host,
isDevelopment,
isProduction,
isTest,
parseEnvBoolean,
parseEnvNumber,
port,
required,
str,
url,
withDefault} from '../src';

describe('env', () => {
const ORIGINAL_ENV = { ...process.env };
Expand Down Expand Up @@ -217,4 +233,160 @@ describe('env', () => {
}).toThrow(/DB_PORT/);
});
});

describe('getNodeEnv (house semantics)', () => {
it('unset NODE_ENV → development', () => {
expect(getNodeEnv({})).toBe('development');
expect(getNodeEnv({ NODE_ENV: '' })).toBe('development');
});

it('explicit production → production', () => {
expect(getNodeEnv({ NODE_ENV: 'production' })).toBe('production');
expect(getNodeEnv({ NODE_ENV: 'PRODUCTION' })).toBe('production');
});

it('explicit test/testing → test', () => {
expect(getNodeEnv({ NODE_ENV: 'test' })).toBe('test');
expect(getNodeEnv({ NODE_ENV: 'testing' })).toBe('test');
});

it('GitHub Actions → test', () => {
expect(getNodeEnv({ GITHUB_ACTIONS: 'true' })).toBe('test');
});

it('predicates agree with getNodeEnv', () => {
expect(isProduction({ NODE_ENV: 'production' })).toBe(true);
expect(isProduction({})).toBe(false);
expect(isTest({ NODE_ENV: 'test' })).toBe(true);
expect(isDevelopment({})).toBe(true);
});
});

describe('fallback classes', () => {
it('withDefault: uses fallback when unset, in every environment', () => {
const dev = env({}, {}, { JOBS_SCHEMA: withDefault(str, 'app_jobs') });
expect(dev.JOBS_SCHEMA).toBe('app_jobs');

const prod = env(
{ NODE_ENV: 'production' },
{},
{ JOBS_SCHEMA: withDefault(str, 'app_jobs') }
);
expect(prod.JOBS_SCHEMA).toBe('app_jobs');
});

it('withDefault: env value overrides the fallback', () => {
const result = env(
{ JOBS_SCHEMA: 'custom_jobs' },
{},
{ JOBS_SCHEMA: withDefault(str, 'app_jobs') }
);
expect(result.JOBS_SCHEMA).toBe('custom_jobs');
});

it('devDefault: uses fallback in development (NODE_ENV unset)', () => {
const result = env(
{},
{},
{ SYNC_GATEWAY_BASE_DOMAIN: devDefault(str, 'sync.localhost') }
);
expect(result.SYNC_GATEWAY_BASE_DOMAIN).toBe('sync.localhost');
});

it('devDefault: uses fallback in test', () => {
const result = env(
{ NODE_ENV: 'test' },
{},
{ SYNC_GATEWAY_BASE_DOMAIN: devDefault(str, 'sync.localhost') }
);
expect(result.SYNC_GATEWAY_BASE_DOMAIN).toBe('sync.localhost');
});

it('devDefault: THROWS in production when absent', () => {
expect(() => {
env(
{ NODE_ENV: 'production' },
{},
{ SYNC_GATEWAY_BASE_DOMAIN: devDefault(str, 'sync.localhost') }
);
}).toThrow(/SYNC_GATEWAY_BASE_DOMAIN/);
});

it('devDefault: env value satisfies the requirement in production', () => {
const result = env(
{ NODE_ENV: 'production', SYNC_GATEWAY_BASE_DOMAIN: 'sync.example.com' },
{},
{ SYNC_GATEWAY_BASE_DOMAIN: devDefault(str, 'sync.localhost') }
);
expect(result.SYNC_GATEWAY_BASE_DOMAIN).toBe('sync.example.com');
});

it('required: throws when absent in development', () => {
expect(() => {
env({}, {}, { K8S_API_URL: required(url) });
}).toThrow(/K8S_API_URL/);
});

it('required: throws when absent in production', () => {
expect(() => {
env({ NODE_ENV: 'production' }, {}, { K8S_API_URL: required(url) });
}).toThrow(/K8S_API_URL/);
});

it('required(url): rejects empty string, accepts a real url', () => {
expect(() => {
env({ K8S_API_URL: '' }, {}, { K8S_API_URL: required(url) });
}).toThrow(/K8S_API_URL/);

const result = env(
{ K8S_API_URL: 'https://k8s.example.com' },
{},
{ K8S_API_URL: required(url) }
);
expect(result.K8S_API_URL).toBe('https://k8s.example.com');
});
});

describe('lenient coercion', () => {
it('parseEnvBoolean accepts true/1/yes case-insensitively', () => {
expect(parseEnvBoolean('true')).toBe(true);
expect(parseEnvBoolean('TRUE')).toBe(true);
expect(parseEnvBoolean('1')).toBe(true);
expect(parseEnvBoolean('Yes')).toBe(true);
expect(parseEnvBoolean('false')).toBe(false);
expect(parseEnvBoolean('no')).toBe(false);
expect(parseEnvBoolean(undefined)).toBeUndefined();
expect(parseEnvBoolean('')).toBeUndefined();
});

it('parseEnvNumber parses finite numbers only', () => {
expect(parseEnvNumber('42')).toBe(42);
expect(parseEnvNumber('3.14')).toBe(3.14);
expect(parseEnvNumber('nan')).toBeUndefined();
expect(parseEnvNumber('')).toBeUndefined();
expect(parseEnvNumber(undefined)).toBeUndefined();
});

it('boolish validator accepts TRUE/yes that envalid bool rejects', () => {
const result = env(
{ FEATURE_ENABLED: 'TRUE' },
{},
{ FEATURE_ENABLED: boolish() }
);
expect(result.FEATURE_ENABLED).toBe(true);
});

it('boolish composes with withDefault (validator runs on typed default)', () => {
const result = env({}, {}, { FEATURE_ENABLED: withDefault(boolish, false) });
expect(result.FEATURE_ENABLED).toBe(false);
});
});

describe('no mutation of caller env', () => {
it('does not add NODE_ENV to the passed-in object', () => {
const input: Record<string, string | undefined> = { JOBS_SCHEMA: 'app_jobs' };
env(input, {}, { JOBS_SCHEMA: withDefault(str, 'app_jobs') });
expect('NODE_ENV' in input).toBe(false);
});
});
});
Loading
Loading