diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index ae9ac24..8c926ea 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -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 @@ -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 diff --git a/docs/getting-started/package-map.md b/docs/getting-started/package-map.md index d7f23c2..e46898a 100644 --- a/docs/getting-started/package-map.md +++ b/docs/getting-started/package-map.md @@ -14,13 +14,13 @@ Bootstrap code chooses adapters. ## Core -| Area | Import | Purpose | -| ---------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| Kernel runtime | `@haskou/ddd-kernel` | Registers consumers, routes, schedulers, runtimes and shutdown hooks. | -| Environment variables | `@haskou/ddd-kernel` | Loads `.env.` files and exposes typed `kernel.environment` when a schema is configured. | -| Dependency injection | `@haskou/ddd-kernel/dependency-injection` | Wraps `node-dependency-injection` and container YAML generation/loading. | -| Lifecycle | `@haskou/ddd-kernel/lifecycle` | Runtime and initializer contracts. | -| Kernel logger contract | `@haskou/ddd-kernel/contracts/kernel` | `KernelLogger` interface. | +| Area | Import | Purpose | +| --------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Kernel runtime | `@haskou/ddd-kernel` | Registers consumers, routes, schedulers, runtimes and shutdown hooks. | +| Environment variables | `@haskou/ddd-kernel` | Loads `.env.` files and exposes typed `kernel.environment` when a schema is configured. | +| Dependency injection | `@haskou/ddd-kernel/dependency-injection` | Wraps `node-dependency-injection` and container YAML generation/loading. | +| Lifecycle | `@haskou/ddd-kernel/lifecycle` | Runtime and initializer contracts. | +| Kernel contracts | `@haskou/ddd-kernel/contracts/kernel` | `KernelConsumer`, `KernelRoute`, `KernelLogger`, middleware and shutdown contracts. | `dotenv`, `node-dependency-injection` and `fs-extra` are package dependencies because the core environment and DI implementations use them directly. diff --git a/docs/reference/consumer.md b/docs/reference/consumer.md index b738b5c..367c884 100644 --- a/docs/reference/consumer.md +++ b/docs/reference/consumer.md @@ -18,6 +18,10 @@ Consumers are registered by class: kernel.registerConsumers(RegisterUserWhenCreated); ``` +`Consumer` implements the core `KernelConsumer` contract. The kernel only +requires `queueName` and `init()`, so a custom transport can provide its own +consumer class without extending this adapter base class. + ## Middleware Register consumer middleware when you need idempotency, retries, tracing or diff --git a/docs/reference/kernel.md b/docs/reference/kernel.md index bab3f4e..5d3c0ed 100644 --- a/docs/reference/kernel.md +++ b/docs/reference/kernel.md @@ -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 }); @@ -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 @@ -145,6 +177,26 @@ kernel.registerConsumerInstances(consumer); kernel.registerSchedulerInstances(scheduler); ``` +`registerConsumers` accepts classes that implement the `KernelConsumer` +contract. The provided pub/sub `Consumer` base class already implements it, but +custom consumers can implement the contract directly when they do not need that +adapter base class: + +```ts +import type { KernelConsumer } from '@haskou/ddd-kernel/contracts/kernel'; + +export default class CustomConsumer implements KernelConsumer { + public readonly queueName = 'custom.consumer'; + + public async init() { + // Subscribe to the transport and bind handlers here. + } +} +``` + +`registerRoutes` accepts classes assignable to `KernelRoute`. The provided HTTP +`Route` base class extends that contract. + Runtimes and initializers are passed to their run methods as classes. Runtimes are resolved through DI, executed and automatically added to shutdown hooks. When an object is already built outside the kernel, register the shutdown action diff --git a/docs/reference/route.md b/docs/reference/route.md index e4259da..4f43494 100644 --- a/docs/reference/route.md +++ b/docs/reference/route.md @@ -19,6 +19,9 @@ injection: kernel.registerRoutes(GetUserRoute); ``` +`Route` extends the core `KernelRoute` contract. HTTP adapters can use that +contract without making the kernel depend on a concrete UI adapter. + `Route` still exposes `get()` for compatibility with older code, but constructor injection is the recommended path because it keeps routes easier to test. diff --git a/package.json b/package.json index 2889f04..9ca7e3d 100644 --- a/package.json +++ b/package.json @@ -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/KernelEnvironmentSchemaInput.ts\" --exclude \"src/kernel/KernelEnvironmentValue.ts\" --exclude \"src/kernel/KernelEnvironmentVariable.ts\" --exclude \"src/kernel/KernelEnvironmentVariablePrimitive.ts\" --exclude \"src/kernel/KernelEnvironmentVariableResolvedValue.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": [ diff --git a/src/Kernel.ts b/src/Kernel.ts index 25be5d0..a6e451a 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -1,11 +1,11 @@ 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 { ConsumerMiddleware, + KernelConsumer, KernelLogger, + KernelRoute, ShutdownHook, } from './contracts/index.js'; import type { ServiceClass } from './infrastructure/dependency-injection/index.js'; @@ -28,9 +28,11 @@ export type { KernelDefaultEnvironment } from './kernel/KernelDefaultEnvironment export type { KernelEnvironment } from './kernel/KernelEnvironment.js'; export type { KernelEnvironmentForSchema } from './kernel/KernelEnvironmentForSchema.js'; export type { KernelEnvironmentSchema } from './kernel/KernelEnvironmentSchema.js'; +export type { KernelEnvironmentSchemaInput } from './kernel/KernelEnvironmentSchemaInput.js'; export type { KernelEnvironmentValue } from './kernel/KernelEnvironmentValue.js'; export type { KernelEnvironmentVariableDefinition } from './kernel/KernelEnvironmentVariableDefinition.js'; export type { KernelEnvironmentVariablePrimitive } from './kernel/KernelEnvironmentVariablePrimitive.js'; +export type { KernelEnvironmentVariableResolvedValue } from './kernel/KernelEnvironmentVariableResolvedValue.js'; export type { KernelEnvironmentVariableType } from './kernel/KernelEnvironmentVariableType.js'; export type { KernelEnvironmentVariablesOptions } from './kernel/KernelEnvironmentVariablesOptions.js'; export type { KernelOptions } from './kernel/KernelOptions.js'; @@ -44,9 +46,9 @@ export class Kernel< ); private readonly consumerMiddlewares: ConsumerMiddleware[] = []; - private readonly consumersList: Consumer[] = []; + private readonly consumersList: KernelConsumer[] = []; private readonly loggerInstance: KernelLogger; - private readonly routesList: ServiceClass[] = []; + private readonly routesList: ServiceClass[] = []; private readonly schedulersList: Scheduler[] = []; private readonly shutdownHooks: ShutdownHook[] = []; private dependencyInjectionInstance: DependencyInjection | undefined; @@ -73,7 +75,7 @@ export class Kernel< return path.resolve(Kernel.rootDirectory, 'config'); } - public static get consumers(): Consumer[] { + public static get consumers(): KernelConsumer[] { return Kernel.getActiveKernel().consumers; } @@ -101,7 +103,7 @@ export class Kernel< return process.cwd(); } - public static get routes(): ServiceClass[] { + public static get routes(): ServiceClass[] { return Kernel.getActiveKernel().routes; } @@ -135,6 +137,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< @@ -193,13 +209,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; } @@ -281,9 +307,9 @@ export class Kernel< } private getConsumerFromClass( - ClassDefinition: ServiceClass, - ): Consumer { - return this.di.getService(ClassDefinition); + ClassDefinition: ServiceClass, + ): KernelConsumer { + return this.di.getService(ClassDefinition); } private getInitializerFromClass( @@ -302,7 +328,7 @@ export class Kernel< return this.di.getService(ClassDefinition); } - public get consumers(): Consumer[] { + public get consumers(): KernelConsumer[] { return this.consumersList; } @@ -326,7 +352,7 @@ export class Kernel< return this.loggerInstance; } - public get routes(): ServiceClass[] { + public get routes(): ServiceClass[] { return this.routesList; } @@ -384,7 +410,7 @@ export class Kernel< return result; } - public getRoutes(): ServiceClass[] { + public getRoutes(): ServiceClass[] { return this.routes; } @@ -395,18 +421,20 @@ export class Kernel< } public registerConsumers( - ...ClassDefinitions: ServiceClass[] + ...ClassDefinitions: ServiceClass[] ): void { for (const ClassDefinition of ClassDefinitions) { this.consumersList.push(this.getConsumerFromClass(ClassDefinition)); } } - public registerConsumerInstances(...consumers: Consumer[]): void { + public registerConsumerInstances(...consumers: KernelConsumer[]): void { this.consumersList.push(...consumers); } - public registerRoutes(...ClassDefinitions: ServiceClass[]): void { + public registerRoutes( + ...ClassDefinitions: ServiceClass[] + ): void { this.routesList.push(...ClassDefinitions); } diff --git a/src/adapters/pubsub/Consumer.ts b/src/adapters/pubsub/Consumer.ts index 4f1cc35..76930d3 100644 --- a/src/adapters/pubsub/Consumer.ts +++ b/src/adapters/pubsub/Consumer.ts @@ -1,3 +1,4 @@ +import type { KernelConsumer } from '../../contracts/kernel/index.js'; import type { DomainEventConsumer } from '../../domain/DomainEventConsumer.js'; import type { DomainEvent, @@ -7,7 +8,7 @@ import type { import { Kernel } from '../../Kernel.js'; import { ConsumerMiddlewarePipeline } from './ConsumerMiddlewarePipeline.js'; -export abstract class Consumer { +export abstract class Consumer implements KernelConsumer { constructor(private readonly consumer: DomainEventConsumer) {} private async runMiddleware( diff --git a/src/adapters/ui/routes/Route.ts b/src/adapters/ui/routes/Route.ts index fc0eaa9..c4e7c28 100644 --- a/src/adapters/ui/routes/Route.ts +++ b/src/adapters/ui/routes/Route.ts @@ -1,6 +1,7 @@ +import { KernelRoute } from '../../../contracts/kernel/index.js'; import { Kernel } from '../../../Kernel.js'; -export abstract class Route { +export abstract class Route extends KernelRoute { public get(service: unknown): T { return Kernel.di.getService(service); } diff --git a/src/contracts/kernel/KernelConsumer.ts b/src/contracts/kernel/KernelConsumer.ts new file mode 100644 index 0000000..9b4c974 --- /dev/null +++ b/src/contracts/kernel/KernelConsumer.ts @@ -0,0 +1,5 @@ +export interface KernelConsumer { + readonly queueName: string; + + init(): Promise; +} diff --git a/src/contracts/kernel/KernelRoute.ts b/src/contracts/kernel/KernelRoute.ts new file mode 100644 index 0000000..b9c00a0 --- /dev/null +++ b/src/contracts/kernel/KernelRoute.ts @@ -0,0 +1,3 @@ +export abstract class KernelRoute { + declare protected readonly kernelRouteContract: never; +} diff --git a/src/contracts/kernel/index.ts b/src/contracts/kernel/index.ts index 93f32c3..c4a6292 100644 --- a/src/contracts/kernel/index.ts +++ b/src/contracts/kernel/index.ts @@ -3,8 +3,10 @@ export * from './ConsumerExecutionContext.js'; export * from './ConsumerNext.js'; export * from './HandlerContext.js'; export * from './IdempotencyStore.js'; +export * from './KernelConsumer.js'; export * from './KernelLogger.js'; export * from './KernelMiddleware.js'; +export * from './KernelRoute.js'; export * from './RetryDelayResolver.js'; export * from './RetryPredicate.js'; export * from './ServiceResolver.js'; diff --git a/src/kernel/KernelEnvironmentSchemaInput.ts b/src/kernel/KernelEnvironmentSchemaInput.ts new file mode 100644 index 0000000..b24f51c --- /dev/null +++ b/src/kernel/KernelEnvironmentSchemaInput.ts @@ -0,0 +1,27 @@ +import type { KernelEnvironmentSchema } from './KernelEnvironmentSchema.js'; +import type { KernelEnvironmentVariableDefinition } from './KernelEnvironmentVariableDefinition.js'; +import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js'; +import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; + +export type KernelEnvironmentSchemaInput< + TSchema extends KernelEnvironmentSchema, +> = { + readonly [TKey in keyof TSchema]: TSchema[TKey] extends { + readonly choices: infer TChoices extends + readonly KernelEnvironmentVariablePrimitive[]; + readonly type: infer TType extends KernelEnvironmentVariableType; + } + ? KernelEnvironmentVariableDefinition< + TType, + TSchema[TKey] extends { readonly required: true } ? true : boolean, + TChoices + > + : TSchema[TKey] extends { + readonly type: infer TType extends KernelEnvironmentVariableType; + } + ? KernelEnvironmentVariableDefinition< + TType, + TSchema[TKey] extends { readonly required: true } ? true : boolean + > + : never; +}; diff --git a/src/kernel/KernelEnvironmentVariable.ts b/src/kernel/KernelEnvironmentVariable.ts index 7d1f6da..a16fe5f 100644 --- a/src/kernel/KernelEnvironmentVariable.ts +++ b/src/kernel/KernelEnvironmentVariable.ts @@ -1,17 +1,10 @@ import type { KernelEnvironmentVariableDefinition } from './KernelEnvironmentVariableDefinition.js'; -import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js'; -import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; +import type { KernelEnvironmentVariableResolvedValue } from './KernelEnvironmentVariableResolvedValue.js'; export type KernelEnvironmentVariable< TDefinition extends KernelEnvironmentVariableDefinition, -> = - TDefinition extends KernelEnvironmentVariableDefinition< - infer TType extends KernelEnvironmentVariableType, - infer TRequired extends boolean - > - ? TDefinition extends { readonly defaultValue: unknown } - ? KernelEnvironmentVariablePrimitive - : TRequired extends true - ? KernelEnvironmentVariablePrimitive - : KernelEnvironmentVariablePrimitive | undefined - : never; +> = TDefinition extends { readonly defaultValue: unknown } + ? KernelEnvironmentVariableResolvedValue + : TDefinition extends { readonly required: true } + ? KernelEnvironmentVariableResolvedValue + : KernelEnvironmentVariableResolvedValue | undefined; diff --git a/src/kernel/KernelEnvironmentVariableDefinition.ts b/src/kernel/KernelEnvironmentVariableDefinition.ts index 25b0cf4..40eae6c 100644 --- a/src/kernel/KernelEnvironmentVariableDefinition.ts +++ b/src/kernel/KernelEnvironmentVariableDefinition.ts @@ -4,8 +4,16 @@ import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableT export interface KernelEnvironmentVariableDefinition< TType extends KernelEnvironmentVariableType = KernelEnvironmentVariableType, TRequired extends boolean = boolean, + TChoices extends + readonly KernelEnvironmentVariablePrimitive[] | undefined = + readonly KernelEnvironmentVariablePrimitive[] | undefined, > { - readonly defaultValue?: KernelEnvironmentVariablePrimitive; + readonly choices?: TChoices; + readonly defaultValue?: TChoices extends readonly KernelEnvironmentVariablePrimitive[] + ? TChoices[number] + : KernelEnvironmentVariablePrimitive; + readonly description?: string; readonly required?: TRequired; + readonly sensitive?: boolean; readonly type: TType; } diff --git a/src/kernel/KernelEnvironmentVariableResolvedValue.ts b/src/kernel/KernelEnvironmentVariableResolvedValue.ts new file mode 100644 index 0000000..766dd64 --- /dev/null +++ b/src/kernel/KernelEnvironmentVariableResolvedValue.ts @@ -0,0 +1,16 @@ +import type { KernelEnvironmentValue } from './KernelEnvironmentValue.js'; +import type { KernelEnvironmentVariableDefinition } from './KernelEnvironmentVariableDefinition.js'; +import type { KernelEnvironmentVariablePrimitive } from './KernelEnvironmentVariablePrimitive.js'; +import type { KernelEnvironmentVariableType } from './KernelEnvironmentVariableType.js'; + +export type KernelEnvironmentVariableResolvedValue< + TDefinition extends KernelEnvironmentVariableDefinition, +> = TDefinition extends { + readonly choices: readonly (infer TChoice extends KernelEnvironmentValue)[]; +} + ? TChoice + : TDefinition extends KernelEnvironmentVariableDefinition< + infer TType extends KernelEnvironmentVariableType + > + ? KernelEnvironmentVariablePrimitive + : never; diff --git a/src/kernel/KernelEnvironmentVariablesOptions.ts b/src/kernel/KernelEnvironmentVariablesOptions.ts index b24f0e0..d636a18 100644 --- a/src/kernel/KernelEnvironmentVariablesOptions.ts +++ b/src/kernel/KernelEnvironmentVariablesOptions.ts @@ -1,9 +1,12 @@ import type { KernelEnvironmentSchema } from './KernelEnvironmentSchema.js'; +import type { KernelEnvironmentSchemaInput } from './KernelEnvironmentSchemaInput.js'; export interface KernelEnvironmentVariablesOptions< TSchema extends KernelEnvironmentSchema | undefined = undefined, > { readonly override?: boolean; readonly path?: string; - readonly schema?: TSchema; + readonly schema?: TSchema extends KernelEnvironmentSchema + ? TSchema & KernelEnvironmentSchemaInput + : TSchema; } diff --git a/src/kernel/KernelOptions.ts b/src/kernel/KernelOptions.ts index 401bd2a..c31f46e 100644 --- a/src/kernel/KernelOptions.ts +++ b/src/kernel/KernelOptions.ts @@ -1,12 +1,15 @@ import type { KernelLogger } from '../contracts/index.js'; import type { DependencyInjection } from '../infrastructure/dependency-injection/index.js'; import type { KernelEnvironmentSchema } from './KernelEnvironmentSchema.js'; +import type { KernelEnvironmentSchemaInput } from './KernelEnvironmentSchemaInput.js'; export interface KernelOptions< TEnvironmentSchema extends KernelEnvironmentSchema | undefined = undefined, > { readonly di?: DependencyInjection; - readonly environmentSchema?: TEnvironmentSchema; + readonly environmentSchema?: TEnvironmentSchema extends KernelEnvironmentSchema + ? TEnvironmentSchema & KernelEnvironmentSchemaInput + : TEnvironmentSchema; readonly logger?: KernelLogger; readonly servicesYamlPath?: string; readonly sourceDirectory?: string; diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index d3c6f15..9f340ca 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -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; + } + } +}); diff --git a/tests/typescript-module-resolution.test.mjs b/tests/typescript-module-resolution.test.mjs index 4891c48..958b26b 100644 --- a/tests/typescript-module-resolution.test.mjs +++ b/tests/typescript-module-resolution.test.mjs @@ -35,22 +35,52 @@ test('exports types for TypeScript moduleResolution node consumers', async () => await writeFile( path.join(temporaryDirectory, 'index.ts'), ` - import type { ConsumerMiddleware } from '@haskou/ddd-kernel/contracts/kernel'; + import type { ConsumerMiddleware, KernelConsumer, KernelRoute } from '@haskou/ddd-kernel/contracts/kernel'; 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 }); + class ArbitraryService {} + 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 invalidNumberChoicesSchema = { + HTTP_PORT: { choices: ['3000'], type: 'number' }, + } as const; + const invalidDefaultChoicesSchema = { + NODE_ENV: { choices: ['local', 'test'], defaultValue: 'production', type: 'string' }, + } as const; const middleware: ConsumerMiddleware | undefined = undefined; + const kernelConsumer: KernelConsumer | undefined = undefined; + const kernelRoute: KernelRoute | undefined = undefined; const messageBus: MessageBus | undefined = undefined; const domainMessageBus: DomainMessageBus | undefined = undefined; const hook: PublisherHook | undefined = undefined; const policy: SchedulerErrorPolicy | undefined = undefined; void middleware; + void kernelConsumer; + void kernelRoute; void messageBus; void domainMessageBus; void hook; + void nodeEnvironment; + void httpPort; + void enableJobs; + // @ts-expect-error choices must match the declared environment variable type. + void new Kernel({ environmentSchema: invalidNumberChoicesSchema }); + // @ts-expect-error defaultValue must be one of the declared choices. + void new Kernel({ environmentSchema: invalidDefaultChoicesSchema }); + // @ts-expect-error registered routes must intentionally extend KernelRoute. + kernel.registerRoutes(ArbitraryService); void policy; void ExpressKernelServer; `,