Skip to content
Closed
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
24 changes: 24 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,46 @@ injection and in-memory adapters.

Install extra packages only for the adapters your application imports.

The core package does not load optional adapter dependencies from the root
import. Install the peer packages listed below only when importing the matching
subpath.

## Pub/Sub Adapters

The in-memory pub/sub adapter has no extra runtime dependencies.

```ts
import { InMemoryPubSub } from '@haskou/ddd-kernel/adapters/pubsub/in-memory';
```

The AMQP adapter uses `amqplib`:

```bash
yarn add amqplib
```

```ts
import { AmqpMessageBusAdapter } from '@haskou/ddd-kernel/adapters/pubsub/amqp';
```

## DB Adapters

The in-memory repository adapter has no extra runtime dependencies.

```ts
import { InMemoryRepository } from '@haskou/ddd-kernel/adapters/db/in-memory';
```

The MongoDB repository adapter uses `mongodb`:

```bash
yarn add mongodb
```

```ts
import { MongoRepository } from '@haskou/ddd-kernel/adapters/db/mongo';
```

## UI Adapters

The Express adapter uses `express`, `routing-controllers` and decorator
Expand All @@ -40,6 +60,10 @@ metadata packages:
yarn add express routing-controllers reflect-metadata class-transformer class-validator
```

```ts
import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express';
```

Install `cors` only when enabling `routingControllersOptions.cors`:

```bash
Expand Down
36 changes: 34 additions & 2 deletions docs/reference/kernel.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,14 @@ creating the kernel:

```ts
const environmentSchema = {
ENABLE_JOBS: { defaultValue: false, type: 'boolean' },
ENABLE_JOBS: {
defaultValue: false,
description: 'Enables background schedulers.',
type: 'boolean',
},
HTTP_PORT: { required: true, type: 'number' },
SERVICE_NAME: { type: 'string' },
NODE_ENV: { choices: ['local', 'test', 'production'], type: 'string' },
SERVICE_NAME: { sensitive: false, type: 'string' },
} as const;

const kernel = new Kernel({ environmentSchema });
Expand All @@ -95,12 +100,39 @@ kernel.loadEnvironmentVariables();

kernel.environment.HTTP_PORT; // number
kernel.environment.ENABLE_JOBS; // boolean
kernel.environment.NODE_ENV; // 'local' | 'test' | 'production' | undefined
kernel.environment.SERVICE_NAME; // string | undefined
```

Required variables throw `KernelEnvironmentValidationError` when they are
missing. `number` and `boolean` values are parsed after `.env` files are loaded.
Boolean values accept `true`, `false`, `1`, `0`, `yes`, `no`, `on` and `off`.
Blank numeric values are rejected instead of being coerced to `0`.

`choices` restricts the allowed runtime values and narrows the TypeScript type
when the schema is declared `as const`:

```ts
const environmentSchema = {
NODE_ENV: { choices: ['local', 'test'], type: 'string' },
} as const;

const kernel = new Kernel({ environmentSchema });

kernel.environment.NODE_ENV; // 'local' | 'test' | undefined
```

Schema entries also accept metadata for generated documentation and operational
tools:

| Field | Purpose |
| -------------- | ----------------------------------------------------------------------- |
| `type` | Runtime parser and TypeScript primitive: `string`, `number`, `boolean`. |
| `required` | Throws when the variable is missing. |
| `defaultValue` | Used when the variable is absent. |
| `choices` | Restricts accepted values and narrows the inferred TypeScript type. |
| `description` | Human-readable explanation for generated docs or audits. |
| `sensitive` | Marks values that should not be logged or displayed by tooling. |

## Dependency Injection

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@
"prepack": "yarn build",
"test": "yarn build && c8 node --test \"tests/**/*.test.mjs\"",
"build:coverage": "rm -rf dist && tsc -p tsconfig.coverage.json",
"test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/**/*.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test \"tests/**/*.test.mjs\"",
"test:coverage": "yarn build:coverage && c8 --all --src src --include \"src/**/*.ts\" --exclude \"src/**/index.ts\" --exclude \"src/contracts/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/**/*Options.ts\" --exclude \"src/**/*Context.ts\" --exclude \"src/**/*Handler.ts\" --exclude \"src/**/*Message.ts\" --exclude \"src/**/*Metadata.ts\" --exclude \"src/**/*Registration.ts\" --exclude \"src/**/*Resolver.ts\" --exclude \"src/**/*Authenticator.ts\" --exclude \"src/**/*Consumer.ts\" --exclude \"src/**/*Publisher.ts\" --exclude \"src/**/*Class.ts\" --exclude \"src/**/*Definition.ts\" --exclude \"src/**/*Alias.ts\" --exclude \"src/**/*Internals.ts\" --exclude \"src/**/*Expression.ts\" --exclude \"src/**/*Constructor.ts\" --exclude \"src/**/*Attributes.ts\" --exclude \"src/infrastructure/lifecycle/**/*.ts\" --exclude \"src/kernel/KernelDefaultEnvironment.ts\" --exclude \"src/kernel/KernelEnvironment.ts\" --exclude \"src/kernel/KernelEnvironmentForSchema.ts\" --exclude \"src/kernel/KernelEnvironmentSchema.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableType.ts\" --exclude \"src/kernel/ShutdownCandidate.ts\" --extension .ts --exclude-after-remap --reporter text --reporter lcov node --test \"tests/**/*.test.mjs\"",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [
Expand Down
32 changes: 28 additions & 4 deletions src/Kernel.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import dotenv, { type DotenvConfigOutput } from 'dotenv';
import path from 'node:path';

import type { Consumer } from './adapters/pubsub/index.js';
import type { Route } from './adapters/ui/routes/index.js';
import type { Consumer } from './adapters/pubsub/Consumer.js';
import type { Route } from './adapters/ui/routes/Route.js';
import type {
ConsumerMiddleware,
KernelLogger,
Expand Down Expand Up @@ -135,6 +135,20 @@ export class Kernel<
}
}

private static assertEnvironmentVariableChoice(
name: string,
value: KernelEnvironmentValue,
schema: KernelEnvironmentSchema,
): void {
const choices = schema[name]?.choices;

if (choices && !choices.includes(value)) {
throw new KernelEnvironmentValidationError(
`Environment variable "${name}" must be one of: ${choices.join(', ')}.`,
);
}
}

private static getEnvironmentVariablesPath(
environment: string,
options: KernelEnvironmentVariablesOptions<
Expand Down Expand Up @@ -193,13 +207,23 @@ export class Kernel<
const definition = schema[name];

if (definition.type === 'boolean') {
return Kernel.parseBooleanEnvironmentVariable(name, value);
const parsedValue = Kernel.parseBooleanEnvironmentVariable(name, value);

Kernel.assertEnvironmentVariableChoice(name, parsedValue, schema);

return parsedValue;
}

if (definition.type === 'number') {
return Kernel.parseNumberEnvironmentVariable(name, value);
const parsedValue = Kernel.parseNumberEnvironmentVariable(name, value);

Kernel.assertEnvironmentVariableChoice(name, parsedValue, schema);

return parsedValue;
}

Kernel.assertEnvironmentVariableChoice(name, value, schema);

return value;
}

Expand Down
18 changes: 14 additions & 4 deletions src/kernel/KernelEnvironmentVariable.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { KernelEnvironmentValue } from './KernelEnvironmentValue.js';
import type { KernelEnvironmentVariableDefinition } from './KernelEnvironmentVariableDefinition.js';
import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js';
import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js';
Expand All @@ -7,11 +8,20 @@ export type KernelEnvironmentVariable<
> =
TDefinition extends KernelEnvironmentVariableDefinition<
infer TType extends KernelEnvironmentVariableType,
infer TRequired extends boolean
infer TRequired extends boolean,
infer TChoices extends readonly KernelEnvironmentValue[] | undefined
>
? TDefinition extends { readonly defaultValue: unknown }
? KernelEnvironmentVariablePrimitive<TType>
? TChoices extends readonly KernelEnvironmentVariablePrimitive<TType>[]
? TChoices[number]
: KernelEnvironmentVariablePrimitive<TType>
: TRequired extends true
? KernelEnvironmentVariablePrimitive<TType>
: KernelEnvironmentVariablePrimitive<TType> | undefined
? TChoices extends readonly KernelEnvironmentVariablePrimitive<TType>[]
? TChoices[number]
: KernelEnvironmentVariablePrimitive<TType>
:
| (TChoices extends readonly KernelEnvironmentVariablePrimitive<TType>[]
? TChoices[number]
: KernelEnvironmentVariablePrimitive<TType>)
| undefined
: never;
7 changes: 7 additions & 0 deletions src/kernel/KernelEnvironmentVariableDefinition.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { KernelEnvironmentValue } from './KernelEnvironmentValue.js';
import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js';
import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js';

export interface KernelEnvironmentVariableDefinition<
TType extends KernelEnvironmentVariableType = KernelEnvironmentVariableType,
TRequired extends boolean = boolean,
TChoices extends readonly KernelEnvironmentValue[] | undefined =
| readonly KernelEnvironmentVariablePrimitive<TType>[]
| undefined,
> {
readonly choices?: TChoices;
Comment on lines +8 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind choices to the declared variable type

When a schema is passed through KernelEnvironmentSchema/new Kernel, TType defaults to the full union, so choices?: TChoices accepts any KernelEnvironmentValue[] rather than choices that match the entry's type. For example, { HTTP_PORT: { type: 'number', choices: ['3000'] } } type-checks, but the runtime parses HTTP_PORT=3000 to the number 3000 and then includes compares it with the string choice, so validation always throws for that config. Please correlate choices with KernelEnvironmentVariablePrimitive<TType> so invalid schemas are rejected at compile time.

Useful? React with 👍 / 👎.

readonly defaultValue?: KernelEnvironmentVariablePrimitive<TType>;
readonly description?: string;
readonly required?: TRequired;
readonly sensitive?: boolean;
readonly type: TType;
}
69 changes: 69 additions & 0 deletions tests/kernel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -731,3 +731,72 @@ test('keeps typed string environment variables as strings', () => {
}
}
});

test('validates typed environment variable choices', () => {
const previousNodeEnvironment = process.env.NODE_ENV;
const previousHttpPort = process.env.HTTP_PORT;
const previousEnableJobs = process.env.ENABLE_JOBS;

process.env.NODE_ENV = 'test';
process.env.HTTP_PORT = '3000';
process.env.ENABLE_JOBS = 'false';

try {
const kernel = new Kernel({
environmentSchema: {
ENABLE_JOBS: { choices: [true, false], type: 'boolean' },
HTTP_PORT: { choices: [3000, 3001], type: 'number' },
NODE_ENV: { choices: ['local', 'test'], type: 'string' },
},
});

kernel.loadEnvironmentVariables();

assert.equal(kernel.environment.NODE_ENV, 'test');
assert.equal(kernel.environment.HTTP_PORT, 3000);
assert.equal(kernel.environment.ENABLE_JOBS, false);
} finally {
if (previousNodeEnvironment === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = previousNodeEnvironment;
}

if (previousHttpPort === undefined) {
delete process.env.HTTP_PORT;
} else {
process.env.HTTP_PORT = previousHttpPort;
}

if (previousEnableJobs === undefined) {
delete process.env.ENABLE_JOBS;
} else {
process.env.ENABLE_JOBS = previousEnableJobs;
}
}
});

test('throws when typed environment variable choices do not match', () => {
const previousNodeEnvironment = process.env.NODE_ENV;

process.env.NODE_ENV = 'production';

try {
const kernel = new Kernel({
environmentSchema: {
NODE_ENV: { choices: ['local', 'test'], type: 'string' },
},
});

assert.throws(
() => kernel.loadEnvironmentVariables(),
/Environment variable "NODE_ENV" must be one of: local, test/,
);
} finally {
if (previousNodeEnvironment === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = previousNodeEnvironment;
}
}
});
13 changes: 13 additions & 0 deletions tests/typescript-module-resolution.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,18 @@ test('exports types for TypeScript moduleResolution node consumers', async () =>
import type { MessageBus, PublisherHook } from '@haskou/ddd-kernel/contracts/pubsub';
import type { DomainMessageBus } from '@haskou/ddd-kernel/domain';
import type { SchedulerErrorPolicy } from '@haskou/ddd-kernel/scheduler';
import Kernel from '@haskou/ddd-kernel';
import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express';

const environmentSchema = {
ENABLE_JOBS: { choices: [true, false], type: 'boolean' },
HTTP_PORT: { choices: [3000, 3001], type: 'number' },
NODE_ENV: { choices: ['local', 'test'], type: 'string' },
} as const;
const kernel = new Kernel({ environmentSchema });
const nodeEnvironment: 'local' | 'test' | undefined = kernel.environment.NODE_ENV;
const httpPort: 3000 | 3001 | undefined = kernel.environment.HTTP_PORT;
const enableJobs: true | false | undefined = kernel.environment.ENABLE_JOBS;
const middleware: ConsumerMiddleware | undefined = undefined;
const messageBus: MessageBus | undefined = undefined;
const domainMessageBus: DomainMessageBus | undefined = undefined;
Expand All @@ -51,6 +61,9 @@ test('exports types for TypeScript moduleResolution node consumers', async () =>
void messageBus;
void domainMessageBus;
void hook;
void nodeEnvironment;
void httpPort;
void enableJobs;
void policy;
void ExpressKernelServer;
`,
Expand Down
Loading