Skip to content
Draft
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
33 changes: 16 additions & 17 deletions .agents/skills/pgpm/references/environment-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,23 +246,15 @@ const envOptions = getEnvVars();
const envOptions = getEnvVars(process.env);
```

### getNodeEnv()
### Shared environment parsers

Get normalized NODE_ENV:
Generic string-to-value parsing is provided by the neutral `12factor-env`
package rather than `@pgpmjs/env`:

```typescript
import { getNodeEnv } from '@pgpmjs/env';

const env = getNodeEnv();
// Returns 'development' | 'production' | 'test'
```

### parseEnvBoolean()

Parse boolean environment variable:
#### parseEnvBoolean()

```typescript
import { parseEnvBoolean } from '@pgpmjs/env';
import { parseEnvBoolean } from '12factor-env/parsers';

parseEnvBoolean('true'); // true
parseEnvBoolean('1'); // true
Expand All @@ -271,18 +263,25 @@ parseEnvBoolean('false'); // false
parseEnvBoolean(undefined); // undefined
```

### parseEnvNumber()

Parse numeric environment variable:
#### parseEnvNumber()

```typescript
import { parseEnvNumber } from '@pgpmjs/env';
import { parseEnvNumber } from '12factor-env/parsers';

parseEnvNumber('5432'); // 5432
parseEnvNumber('invalid'); // undefined
parseEnvNumber(undefined); // undefined
```

#### parseEnvStringArray()

```typescript
import { parseEnvStringArray } from '12factor-env/parsers';

parseEnvStringArray('jobs,email'); // ['jobs', 'email']
parseEnvStringArray(undefined); // undefined
```

## pgpm.json Configuration

Example `pgpm.json` with environment options:
Expand Down
2 changes: 1 addition & 1 deletion functions/send-email/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@
"makage": "^0.3.0"
},
"dependencies": {
"12factor-env": "workspace:^",
"@constructive-io/knative-job-fn": "workspace:^",
"@constructive-io/postmaster": "workspace:^",
"@pgpmjs/env": "workspace:^",
"@pgpmjs/logger": "workspace:^",
"simple-smtp-server": "workspace:^"
}
Expand Down
2 changes: 1 addition & 1 deletion functions/send-email/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createJobApp } from '@constructive-io/knative-job-fn';
import { send as sendSmtp } from 'simple-smtp-server';
import { send as sendPostmaster } from '@constructive-io/postmaster';
import { parseEnvBoolean } from '@pgpmjs/env';
import { parseEnvBoolean } from '12factor-env/parsers';
import { createLogger } from '@pgpmjs/logger';

type SimpleEmailPayload = {
Expand Down
2 changes: 1 addition & 1 deletion functions/send-verification-link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@
"makage": "^0.3.0"
},
"dependencies": {
"12factor-env": "workspace:^",
"@constructive-io/knative-job-fn": "workspace:^",
"@constructive-io/postmaster": "workspace:^",
"@launchql/mjml": "0.1.1",
"@launchql/styled-email": "0.1.0",
"@pgpmjs/env": "workspace:^",
"@pgpmjs/logger": "workspace:^",
"graphql-request": "^7.1.2",
"graphql-tag": "^2.12.6",
Expand Down
2 changes: 1 addition & 1 deletion functions/send-verification-link/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import gql from 'graphql-tag';
import { generate } from '@launchql/mjml';
import { send as sendPostmaster } from '@constructive-io/postmaster';
import { send as sendSmtp } from 'simple-smtp-server';
import { parseEnvBoolean } from '@pgpmjs/env';
import { parseEnvBoolean } from '12factor-env/parsers';
import { createLogger } from '@pgpmjs/logger';

const isDryRun = parseEnvBoolean(process.env.SEND_VERIFICATION_LINK_DRY_RUN ?? process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false;
Expand Down
4 changes: 4 additions & 0 deletions graphql/env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ PGPM and Constructive defaults

In addition to the PostgreSQL/PGPM values supplied by `@pgpmjs/env`, this package parses the Constructive-owned variables below.

The generic boolean, number, and comma-separated string conversions come from `12factor-env/parsers`. GraphQL env continues to own the variable names, defaults, output structure, and merge behavior listed here.

### Server

- `PORT`
Expand Down Expand Up @@ -139,3 +141,5 @@ Constructive defaults are provided by `@constructive-io/graphql-types`. They inc
## Follow-up boundary

This refactor moves only server, CDN/storage, jobs, SMTP, and `getNodeEnv()` ownership out of PGPM. It does not consolidate environment variables currently managed by Mailgun providers, individual functions, Knative runtimes, LLM integrations, observability, CAPTCHA, Graphile runtime helpers, or test harnesses. Existing behavior in those areas remains unchanged and can be addressed separately.

Common parser functions are centralized in `12factor-env`, including the compatible boolean helper used by several jobs and function runtimes. That shared mechanism does not transfer ownership of those runtimes' environment variables to GraphQL env or `12factor-env`.
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
30 changes: 8 additions & 22 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,11 @@ import {
BucketProvider,
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());
};

const parseEnvNumber = (val?: string): number | undefined => {
const num = Number(val);
return !isNaN(num) ? num : undefined;
};

const parseEnvStringArray = (val?: string): string[] | undefined => {
if (!val) return undefined;
return val
.split(',')
.map(s => s.trim())
.filter(Boolean);
};
import {
parseEnvBoolean,
parseEnvNumber,
parseEnvStringArray
} from '12factor-env/parsers';

type NodeEnv = 'development' | 'production' | 'test';

Expand All @@ -34,6 +17,9 @@ export const getNodeEnv = (): NodeEnv => {
};

/**
* Parse GraphQL-related environment variables.
* These are the env vars that Constructive packages need but pgpm doesn't.
*
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
*/
export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial<ConstructiveOptions> => {
Expand Down
2 changes: 1 addition & 1 deletion jobs/job-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@
"makage": "^0.3.0"
},
"dependencies": {
"12factor-env": "workspace:^",
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@pgpmjs/env": "workspace:^",
"@pgpmjs/logger": "workspace:^",
"pg-cache": "workspace:^",
"pg-env": "workspace:^"
Expand Down
7 changes: 1 addition & 6 deletions jobs/job-utils/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { getEnvOptions, getNodeEnv } from '@constructive-io/graphql-env';
import { jobsDefaults, type ConstructiveOptions } from '@constructive-io/graphql-types';
import { parseEnvBoolean } from '@pgpmjs/env';
import { parseEnvBoolean } from '12factor-env/parsers';
import { defaultPgConfig, getPgEnvVars, type PgConfig } from 'pg-env';
import { buildConnectionString, getPgPool } from 'pg-cache';
import type { Pool } from 'pg';

type Maybe<T> = T | null | undefined;

const toStrArray = (v: Maybe<string>): string[] | undefined =>
v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined;

// ---- PG config ----
export const getJobPgConfig = (): PgConfig => {
const opts: ConstructiveOptions = getEnvOptions();
Expand Down
2 changes: 1 addition & 1 deletion jobs/knative-job-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"ts-node": "^10.9.2"
},
"dependencies": {
"12factor-env": "workspace:^",
"@constructive-io/job-pg": "workspace:^",
"@constructive-io/job-scheduler": "workspace:^",
"@constructive-io/job-utils": "workspace:^",
Expand All @@ -64,7 +65,6 @@
"@constructive-io/knative-job-worker": "workspace:^",
"@constructive-io/send-email-fn": "workspace:^",
"@constructive-io/send-verification-link-fn": "workspace:^",
"@pgpmjs/env": "workspace:^",
"@pgpmjs/logger": "workspace:^",
"async-retry": "1.3.3",
"pg": "8.21.0"
Expand Down
15 changes: 5 additions & 10 deletions jobs/knative-job-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
getSchedulerHostname,
getWorkerHostname
} from '@constructive-io/job-utils';
import { parseEnvBoolean } from '@pgpmjs/env';
import {
parseEnvBoolean,
parseEnvStringArray
} from '12factor-env/parsers';
import { Logger } from '@pgpmjs/logger';
import retry from 'async-retry';
import { Client } from 'pg';
Expand Down Expand Up @@ -289,14 +292,6 @@ export class KnativeJobsSvc {
}
}

const parseList = (value?: string): string[] => {
if (!value) return [];
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
};

const parsePortMap = (value?: string): Record<string, number> => {
if (!value) return {};

Expand Down Expand Up @@ -339,7 +334,7 @@ const buildFunctionsOptionsFromEnv = (): KnativeJobsSvcOptions['functions'] => {
return { enabled: true };
}

const names = parseList(rawFunctions) as FunctionName[];
const names = (parseEnvStringArray(rawFunctions) ?? []) as FunctionName[];
if (!names.length) return undefined;

const services: FunctionServiceConfig[] = names.map((name) => ({
Expand Down
38 changes: 38 additions & 0 deletions packages/12factor-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ console.log(config.DATABASE_URL); // Validated string
console.log(config.PORT); // Validated number
```

## Permissive Parsers

For callers that already decide whether a value is required and only need the
historical Constructive conversion semantics, the package provides three pure
parsers:

```ts
import {
parseEnvBoolean,
parseEnvNumber,
parseEnvStringArray,
} from '12factor-env/parsers';

parseEnvBoolean('yes'); // true
parseEnvNumber('3000'); // 3000
parseEnvStringArray('email, thumbnail'); // ['email', 'thumbnail']
```

They are also exported from the package root. The `parsers` entry has no access
to `process.env`, the filesystem, secret files, or envalid; it only converts the
value passed by the caller.

These parsers are deliberately permissive to preserve existing runtime
behavior:

- `parseEnvBoolean()` returns `undefined` only for an absent value. It returns
`true` for `true`, `1`, or `yes` (case-insensitive), and `false` for every
other defined string.
- `parseEnvNumber()` uses JavaScript `Number()` conversion and returns
`undefined` only when the result is `NaN`.
- `parseEnvStringArray()` splits on commas, trims entries, and removes empty
entries.

Use the envalid-backed validators such as `bool()`, `num()`, and `port()` when
invalid or missing configuration must be rejected. Use the pure parsers when
the caller owns defaults and validation and compatibility with the permissive
conversion rules is required.

## 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
66 changes: 66 additions & 0 deletions packages/12factor-env/__tests__/package-exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { execFileSync } from 'child_process';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';

describe('published package exports', () => {
const packageRoot = resolve(__dirname, '..');
const distRoot = join(packageRoot, 'dist');
let fixtureRoot: string;

beforeAll(() => {
execFileSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
['run', 'build'],
{ cwd: packageRoot, stdio: 'pipe' }
);

fixtureRoot = mkdtempSync(join(tmpdir(), '12factor-env-exports-'));
const nodeModules = join(fixtureRoot, 'node_modules');
mkdirSync(nodeModules);
symlinkSync(
distRoot,
join(nodeModules, '12factor-env'),
process.platform === 'win32' ? 'junction' : 'dir'
);
});

afterAll(() => {
rmSync(fixtureRoot, { recursive: true, force: true });
});

it('loads the root and parser entries through CommonJS', () => {
execFileSync(
process.execPath,
[
'-e',
[
"const root = require('12factor-env');",
"const parsers = require('12factor-env/parsers');",
"if (typeof root.env !== 'function') process.exit(1);",
"if (parsers.parseEnvBoolean('YES') !== true) process.exit(1);",
"require.resolve('12factor-env/index.js');",
"require.resolve('12factor-env/esm/index.js');",
].join('\n'),
],
{ cwd: fixtureRoot, stdio: 'pipe' }
);
});

it('loads the root and parser entries through native ESM', () => {
execFileSync(
process.execPath,
[
'--input-type=module',
'-e',
[
"import { env } from '12factor-env';",
"import { parseEnvStringArray } from '12factor-env/parsers';",
"if (typeof env !== 'function') process.exit(1);",
"if (parseEnvStringArray('a, b').join(':') !== 'a:b') process.exit(1);",
].join('\n'),
],
{ cwd: fixtureRoot, stdio: 'pipe' }
);
});
});
Loading