From 8c24dfe055b80bc0d0290e27ae8b274665bb118e Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 8 Apr 2026 13:09:05 +0100 Subject: [PATCH 1/3] implemented namespaced config --- src/config.module.ts | 30 ++- src/constants.ts | 48 ++++- src/decorators/inject-config.decorator.ts | 72 +++++++ src/define-namespace.ts | 246 ++++++++++++++++++++++ src/index.ts | 11 + 5 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 src/decorators/inject-config.decorator.ts create mode 100644 src/define-namespace.ts diff --git a/src/config.module.ts b/src/config.module.ts index 6eac0b8..33f66bc 100644 --- a/src/config.module.ts +++ b/src/config.module.ts @@ -23,6 +23,8 @@ * - Validation runs BEFORE any other provider resolves. * - If validation fails, ConfigValidationError is thrown and the app * never finishes booting. + * - A shared namespace registry (Set) is exported so that + * NamespacedConfig.asProvider() can detect duplicate namespaces. * * Contents: * - ConfigModuleAsyncOptions — interface for registerAsync options @@ -33,7 +35,7 @@ import { DynamicModule, Global, Module } from "@nestjs/common"; import type { Provider } from "@nestjs/common"; import type { z } from "zod"; -import { CONFIG_VALUES_TOKEN } from "@/constants"; +import { CONFIG_VALUES_TOKEN, NAMESPACE_REGISTRY_TOKEN } from "@/constants"; import type { ConfigDefinition } from "@/define-config"; import { ConfigService } from "@/config.service"; @@ -145,14 +147,24 @@ export class ConfigModule { useValue: parsedConfig, }; + // Namespace registry — a shared Set that NamespacedConfig.asProvider() + // injects to detect duplicate namespace registrations at module init time. + // A fresh Set is created per ConfigModule instance. + const namespaceRegistryProvider: Provider = { + provide: NAMESPACE_REGISTRY_TOKEN, + useValue: new Set(), + }; + return { module: ConfigModule, providers: [ - configValuesProvider, // must be listed before ConfigService (which depends on it) + configValuesProvider, // parsed config values — must come before ConfigService + namespaceRegistryProvider, // registry for namespace duplicate detection ConfigService, ], - // Export ConfigService so the importing module can inject it - exports: [ConfigService], + // Export both ConfigService and the registry so feature modules' asProvider() + // can inject NAMESPACE_REGISTRY_TOKEN + exports: [ConfigService, NAMESPACE_REGISTRY_TOKEN], // Non-global: only available in the importing module unless re-exported global: false, }; @@ -201,12 +213,18 @@ export class ConfigModule { inject: (options.inject ?? []) as never[], }; + // Namespace registry shared with feature modules' asProvider() factories + const namespaceRegistryProvider: Provider = { + provide: NAMESPACE_REGISTRY_TOKEN, + useValue: new Set(), + }; + return { module: ConfigModule, // Make the imported modules available so the factory can resolve them imports: options.imports ?? [], - providers: [configValuesProvider, ConfigService], - exports: [ConfigService], + providers: [configValuesProvider, namespaceRegistryProvider, ConfigService], + exports: [ConfigService, NAMESPACE_REGISTRY_TOKEN], global: false, }; } diff --git a/src/constants.ts b/src/constants.ts index 5fef031..5e64284 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,18 +6,20 @@ * These tokens are the "glue" between ConfigModule providers and the * services that consume them. They are NOT part of the public API — * consumers should never inject these tokens directly; they should use - * ConfigService instead. + * ConfigService or @InjectConfig() instead. * * Contents: - * - CONFIG_VALUES_TOKEN — token for the parsed, frozen config object + * - CONFIG_VALUES_TOKEN — token for the root parsed config object + * - getNamespaceToken() — generates a unique DI token per namespace + * - NAMESPACE_REGISTRY_TOKEN — token for the set of registered namespace names */ // ───────────────────────────────────────────────────────────────────────────── -// DI Tokens +// Root config token // ───────────────────────────────────────────────────────────────────────────── /** - * Injection token for the parsed and validated config object. + * Injection token for the parsed and validated root config object. * * ConfigModule registers the result of `definition.parse(process.env)` under * this token. ConfigService then injects it to serve typed `get()` calls. @@ -25,3 +27,41 @@ * Consumers should never inject this token directly — always use ConfigService. */ export const CONFIG_VALUES_TOKEN = "CONFIG_KIT_VALUES" as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Namespace tokens +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Generates a unique, stable DI token for a given namespace string. + * + * Each namespace gets its own token so that NestJS can inject the correct + * validated config slice into each feature module independently. + * + * The token is a plain string prefixed with `CONFIG_KIT_NS:` to avoid any + * accidental collision with other DI tokens in the consuming app. + * + * @param namespace - The namespace name passed to `defineNamespace()`. + * @returns A unique DI token string for that namespace. + * + * @example + * ```typescript + * getNamespaceToken('database') // → 'CONFIG_KIT_NS:database' + * getNamespaceToken('auth') // → 'CONFIG_KIT_NS:auth' + * ``` + */ +export function getNamespaceToken(namespace: string): string { + return `CONFIG_KIT_NS:${namespace}`; +} + +/** + * Injection token for the namespace registry. + * + * ConfigModule stores a `Set` of all registered namespace names under + * this token at startup. When a new NamespacedConfig is added, it checks this + * registry and throws if the namespace has already been registered — preventing + * silent duplicate registrations that would produce unpredictable behavior. + * + * This token is internal; consumers never interact with it directly. + */ +export const NAMESPACE_REGISTRY_TOKEN = "CONFIG_KIT_NS_REGISTRY" as const; diff --git a/src/decorators/inject-config.decorator.ts b/src/decorators/inject-config.decorator.ts new file mode 100644 index 0000000..c1d1288 --- /dev/null +++ b/src/decorators/inject-config.decorator.ts @@ -0,0 +1,72 @@ +/** + * @file inject-config.decorator.ts + * @description + * Parameter decorator for injecting a typed namespace config slice into + * NestJS constructors. + * + * Usage: + * ```typescript + * constructor( + * @InjectConfig('auth') private cfg: z.output + * ) {} + * ``` + * + * Under the hood it is just `@Inject(getNamespaceToken(namespace))` — a thin + * wrapper so consumers never have to know about the internal token format. + * + * Contents: + * - InjectConfig() — parameter decorator factory + */ + +import { Inject } from "@nestjs/common"; + +import { getNamespaceToken } from "@/constants"; + +// ───────────────────────────────────────────────────────────────────────────── +// @InjectConfig +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Parameter decorator that injects the validated config slice for a namespace. + * + * Must be used in constructors of NestJS providers (services, controllers, etc.) + * inside a module that has imported ConfigModule and added the corresponding + * `NamespacedConfig.asProvider()` to its `providers` array. + * + * The injected value is a frozen, fully-typed object — the Zod output of the + * schema passed to `defineNamespace()`. No `string | undefined` values. + * + * @param namespace - The namespace name used in `defineNamespace(namespace, schema)`. + * Must match exactly (case-sensitive). + * @returns A NestJS `@Inject()` parameter decorator bound to the namespace token. + * + * @example + * ```typescript + * // auth/auth.config.ts + * export const authConfig = defineNamespace('auth', z.object({ + * JWT_SECRET: z.string().min(32), + * JWT_EXPIRES_IN: z.string().default('7d'), + * })); + * + * // auth/auth.module.ts + * @Module({ providers: [authConfig.asProvider(), AuthService] }) + * export class AuthModule {} + * + * // auth/auth.service.ts + * @Injectable() + * export class AuthService { + * constructor( + * // Injects the validated { JWT_SECRET: string, JWT_EXPIRES_IN: string } object + * @InjectConfig('auth') private cfg: z.output + * ) {} + * + * getSecret(): string { + * return this.cfg.JWT_SECRET; // string — never undefined + * } + * } + * ``` + */ +export function InjectConfig(namespace: string): ParameterDecorator { + // Resolve the namespace to its unique DI token and delegate to NestJS @Inject + return Inject(getNamespaceToken(namespace)); +} diff --git a/src/define-namespace.ts b/src/define-namespace.ts new file mode 100644 index 0000000..b28b17d --- /dev/null +++ b/src/define-namespace.ts @@ -0,0 +1,246 @@ +/** + * @file define-namespace.ts + * @description + * Per-module scoped configuration for ConfigKit. + * + * Each NestJS feature module that needs config defines its own slice using + * `defineNamespace(name, schema)`. The returned `NamespacedConfig` object + * is self-contained — it knows its name, its Zod schema, and how to register + * itself as a NestJS provider. + * + * How it fits into the system: + * 1. A feature module calls `defineNamespace('db', z.object({ ... }))`. + * 2. It adds `NamespacedConfig.asProvider()` to the `providers` array of its + * NestJS module. + * 3. ConfigModule (via forRoot / register) parses ALL namespaced schemas as + * part of the same validation pass, so misconfigured slices also prevent + * the app from booting. + * 4. Feature constructors inject the typed slice with `@InjectConfig('db')`. + * + * Contents: + * - DuplicateNamespaceError — thrown when the same namespace is registered twice + * - NamespacedConfig — holds the namespace name, schema, and asProvider() + * - defineNamespace() — public factory function + * + * Acceptance criteria covered: + * - AC1: defineNamespace(namespace, schema) returns NamespacedConfig + * - AC2: NamespacedConfig registers its own injection token + * - AC3: Feature modules use NamespacedConfig.asProvider() + * - AC6: Duplicate namespace registration throws at module init + */ + +import { type Provider } from "@nestjs/common"; +import type { z } from "zod"; + +import { getNamespaceToken, NAMESPACE_REGISTRY_TOKEN } from "@/constants"; +import type { ConfigDefinition } from "@/define-config"; +import { defineConfig } from "@/define-config"; + +// ───────────────────────────────────────────────────────────────────────────── +// Internal type helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Constrains Zod schemas to ZodObject so keys can be accessed. */ +type AnyZodObject = z.ZodObject; + +// ───────────────────────────────────────────────────────────────────────────── +// Errors +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Thrown synchronously during NestJS module initialization when the same + * namespace name is registered more than once. + * + * Duplicate registrations would silently overwrite each other's DI providers, + * causing one feature module to receive another's config — this error prevents + * that from ever happening. + * + * @example + * ```typescript + * // Two feature modules both call defineNamespace('auth', ...) + * // → DuplicateNamespaceError: Namespace "auth" is already registered. + * ``` + */ +export class DuplicateNamespaceError extends Error { + constructor(namespace: string) { + super( + `Namespace "${namespace}" is already registered. ` + + `Each namespace must be unique across the entire application. ` + + `Check that no two modules call defineNamespace('${namespace}', ...).`, + ); + // Ensure instanceof checks work correctly after TypeScript transpilation + Object.setPrototypeOf(this, new.target.prototype); + this.name = "DuplicateNamespaceError"; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// NamespacedConfig +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Holds a named, scoped config slice for a single feature module. + * + * Created by `defineNamespace()` — do not instantiate directly. + * + * The generic `T` carries the Zod schema type so that `@InjectConfig(namespace)` + * can infer the exact output type when used in TypeScript constructors. + * + * @typeParam T - The Zod ZodObject schema for this namespace. + * + * @example + * ```typescript + * // database/database.config.ts + * export const dbConfig = defineNamespace('database', + * z.object({ + * DATABASE_URL: z.string().url(), + * DB_POOL_SIZE: z.coerce.number().default(5), + * }), + * ); + * + * // database/database.module.ts + * @Module({ + * providers: [dbConfig.asProvider(), DatabaseService], + * }) + * export class DatabaseModule {} + * + * // database/database.service.ts + * constructor(@InjectConfig('database') private cfg: typeof dbConfig.schema._output) {} + * ``` + */ +export class NamespacedConfig { + /** + * The unique name for this config slice. + * Used as the key in `@InjectConfig(namespace)` and to generate the DI token. + */ + public readonly namespace: string; + + /** + * The internal ConfigDefinition wrapping the Zod schema. + * Used by `asProvider()` to parse process.env at module init time. + */ + public readonly definition: ConfigDefinition; + + constructor(namespace: string, schema: T) { + // Store the namespace name for token lookups and duplicate detection + this.namespace = namespace; + // Wrap the raw Zod schema in a ConfigDefinition to reuse its parse() logic + this.definition = defineConfig(schema); + } + + /** + * Produces a NestJS `Provider` that parses and validates this namespace's + * slice of `process.env` at module initialization time. + * + * **How to use**: Add the result to the `providers` array of your feature + * module. NestJS will run the factory synchronously before any provider + * that depends on this namespace's token resolves. + * + * **Duplicate detection**: The factory injects the namespace registry (a + * `Set` populated by ConfigModule) and throws + * `DuplicateNamespaceError` if this namespace has already been registered. + * + * **Validation**: After the duplicate check, `definition.parse(process.env)` + * runs — if any env vars in this slice are invalid, `ConfigValidationError` + * is thrown and the app never finishes booting. + * + * @returns A NestJS `Provider` that provides the validated config slice under + * the namespace-specific DI token. + * + * @example + * ```typescript + * @Module({ + * providers: [ + * dbConfig.asProvider(), // ← add this + * DatabaseService, + * ], + * }) + * export class DatabaseModule {} + * ``` + */ + asProvider(): Provider { + const { namespace, definition } = this; + // The unique token for this namespace — same token @InjectConfig() uses + const token = getNamespaceToken(namespace); + + return { + provide: token, + useFactory: (registry: Set) => { + // ── Duplicate detection ────────────────────────────────────────────── + // registry is the Set populated by ConfigModule at startup. + // If this namespace is already there, two modules registered the same + // name — throw immediately so the misconfiguration is obvious. + if (registry.has(namespace)) { + throw new DuplicateNamespaceError(namespace); + } + + // Mark this namespace as registered so subsequent registrations fail + registry.add(namespace); + + // ── Validation ─────────────────────────────────────────────────────── + // Parse this namespace's schema against process.env. + // Throws ConfigValidationError if any required vars are missing/invalid. + return definition.parse(process.env); + }, + // Inject the shared namespace registry from ConfigModule + inject: [NAMESPACE_REGISTRY_TOKEN], + }; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// defineNamespace +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Declares a scoped, named config slice for a single feature module. + * + * Returns a `NamespacedConfig` that the feature module uses to: + * - Register a validated provider via `.asProvider()` + * - Type the injected value via `@InjectConfig(namespace)` + * + * The namespace name must be unique across the entire application — attempting + * to call `asProvider()` on two configs with the same name will throw + * `DuplicateNamespaceError` at module init. + * + * @param namespace - A unique string identifier for this config slice. + * Use a domain-meaningful name like `'database'`, `'auth'`, + * `'email'`. Must be the same string passed to + * `@InjectConfig(namespace)`. + * @param schema - A `z.object(...)` describing the env vars this module needs. + * @returns `NamespacedConfig` — call `.asProvider()` in your module's providers. + * + * @example + * ```typescript + * // auth/auth.config.ts + * import { defineNamespace } from '@ciscode/config-kit'; + * import { z } from 'zod'; + * + * export const authConfig = defineNamespace('auth', + * z.object({ + * JWT_SECRET: z.string().min(32), + * JWT_EXPIRES_IN: z.string().default('7d'), + * }), + * ); + * + * // auth/auth.module.ts + * @Module({ providers: [authConfig.asProvider(), AuthService] }) + * export class AuthModule {} + * + * // auth/auth.service.ts + * constructor( + * @InjectConfig('auth') private cfg: z.output + * ) {} + * + * getSecret(): string { + * return this.cfg.JWT_SECRET; // string — never undefined + * } + * ``` + */ +export function defineNamespace( + namespace: string, + schema: T, +): NamespacedConfig { + // No side effects at definition time — validation is deferred to asProvider() + return new NamespacedConfig(namespace, schema); +} diff --git a/src/index.ts b/src/index.ts index d088276..c9755e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,17 @@ export { ConfigModule } from "./config.module"; export type { ConfigModuleAsyncOptions } from "./config.module"; export { ConfigService } from "./config.service"; +// ============================================================================ +// NAMESPACED CONFIG (COMPT-52) +// ============================================================================ +// defineNamespace() — declare a scoped config slice for a feature module +// NamespacedConfig — holds namespace name, schema, and asProvider() +// InjectConfig() — @InjectConfig('namespace') parameter decorator +// DuplicateNamespaceError — thrown when same namespace is registered twice +// ============================================================================ +export { defineNamespace, NamespacedConfig, DuplicateNamespaceError } from "./define-namespace"; +export { InjectConfig } from "./decorators/inject-config.decorator"; + // ============================================================================ // ERRORS // ============================================================================ From cb29815417925a194a4bed7681d8a045f81c5ce7 Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 8 Apr 2026 13:11:35 +0100 Subject: [PATCH 2/3] fixed prettier error --- src/config.module.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/config.module.ts b/src/config.module.ts index 33f66bc..df1ed2d 100644 --- a/src/config.module.ts +++ b/src/config.module.ts @@ -84,9 +84,7 @@ export interface ConfigModuleAsyncOptions * Factory function that returns the ConfigDefinition to validate. * May be synchronous or async. */ - useFactory: ( - ...args: unknown[] - ) => Promise> | ConfigDefinition; + useFactory: (...args: unknown[]) => Promise> | ConfigDefinition; } // ───────────────────────────────────────────────────────────────────────────── @@ -158,7 +156,7 @@ export class ConfigModule { return { module: ConfigModule, providers: [ - configValuesProvider, // parsed config values — must come before ConfigService + configValuesProvider, // parsed config values — must come before ConfigService namespaceRegistryProvider, // registry for namespace duplicate detection ConfigService, ], From fd1fbddc99b16fb2bb5aa1267390e1827bc0f74a Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 8 Apr 2026 13:12:47 +0100 Subject: [PATCH 3/3] fix(lint): fix import order in config.module.ts --- src/config.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.module.ts b/src/config.module.ts index df1ed2d..2d83869 100644 --- a/src/config.module.ts +++ b/src/config.module.ts @@ -35,9 +35,9 @@ import { DynamicModule, Global, Module } from "@nestjs/common"; import type { Provider } from "@nestjs/common"; import type { z } from "zod"; +import { ConfigService } from "@/config.service"; import { CONFIG_VALUES_TOKEN, NAMESPACE_REGISTRY_TOKEN } from "@/constants"; import type { ConfigDefinition } from "@/define-config"; -import { ConfigService } from "@/config.service"; // ───────────────────────────────────────────────────────────────────────────── // Internal type helpers