diff --git a/docs/development_plan.md b/docs/development_plan.md index ff87591..963971c 100644 --- a/docs/development_plan.md +++ b/docs/development_plan.md @@ -23,7 +23,7 @@ ## 1. Progress Dashboard -> **Overall progress: 4 / 8 phases (50%)** +> **Overall progress: 5 / 8 phases (63%)** > **Active phase:** none > **Blocked phases:** none @@ -35,7 +35,7 @@ | P1 | schema-engine | โœ… | 100% | M | 2026-07-16 | | P2 | validation-pipeline | โœ… | 100% | M | 2026-07-16 | | P3 | dynamic-module-di | โœ… | 100% | M | 2026-07-16 | -| P4 | typed-accessor | ๐Ÿ“‹ | 0% | S | 2026-07-06 | +| P4 | typed-accessor | โœ… | 100% | S | 2026-07-16 | | P5 | testing-subpath | ๐Ÿ“‹ | 0% | M | 2026-07-06 | | P6 | integration-docs-dogfood | ๐Ÿ“‹ | 0% | M | 2026-07-06 | | P7 | mutation-hardening-release | ๐Ÿ“‹ | 0% | L | 2026-07-06 | diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 19c3e5c..d3fba52 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -17,11 +17,11 @@ Tasks live **one file per phase** in this folder (`phase-NN-.md`). Each ph | 1 | [`phase-01-schema-engine.md`](./phase-01-schema-engine.md) | 5 / 5 | โœ… Done | | 2 | [`phase-02-validation-pipeline.md`](./phase-02-validation-pipeline.md) | 5 / 5 | โœ… Done | | 3 | [`phase-03-dynamic-module-di.md`](./phase-03-dynamic-module-di.md) | 5 / 5 | โœ… Done | -| 4 | [`phase-04-typed-accessor.md`](./phase-04-typed-accessor.md) | 0 / 4 | ๐Ÿ“‹ ToDo | +| 4 | [`phase-04-typed-accessor.md`](./phase-04-typed-accessor.md) | 4 / 4 | โœ… Done | | 5 | [`phase-05-testing-subpath.md`](./phase-05-testing-subpath.md) | 0 / 4 | ๐Ÿ“‹ ToDo | | 6 | [`phase-06-integration-docs-dogfood.md`](./phase-06-integration-docs-dogfood.md) | 0 / 5 | ๐Ÿ“‹ ToDo | | 7 | [`phase-07-mutation-hardening-release.md`](./phase-07-mutation-hardening-release.md) | 0 / 5 | ๐Ÿ“‹ ToDo | -| | **Total** | **21 / 39** | ๐Ÿ”„ In Progress | +| | **Total** | **25 / 39** | ๐Ÿ”„ In Progress | --- diff --git a/docs/tasks/phase-04-typed-accessor.md b/docs/tasks/phase-04-typed-accessor.md index d3be8ea..63ec859 100644 --- a/docs/tasks/phase-04-typed-accessor.md +++ b/docs/tasks/phase-04-typed-accessor.md @@ -1,6 +1,6 @@ # Phase 4: typed-accessor -> **Status**: ๐Ÿ“‹ ToDo ยท **Progress**: 0 / 4 tasks ยท **Last updated**: 2026-07-06 +> **Status**: โœ… Done ยท **Progress**: 4 / 4 tasks ยท **Last updated**: 2026-07-16 > **Source roadmap**: [`../development_plan.md`](../development_plan.md) ยง5 (P4) > **Source spec**: [`../technical_specification.md`](../technical_specification.md) ยง5, ยง12.1 @@ -33,12 +33,12 @@ Phase 1 is merged. This phase runs in parallel with Phases 2 and 3 (disjoint fil ## Task index -| ID | Task | Status | Priority | Size | Depends on | -|---|---|---|---|---|---| -| 4.1 | Branch + `Path` / `PathValue` type utilities + type tests | ๐Ÿ“‹ ToDo | P0 | M | none | -| 4.2 | `ConfigService`: `get`, `getAll`, `has` | ๐Ÿ“‹ ToDo | P0 | M | 4.1 | -| 4.3 | Service registration in the module + global injection test | ๐Ÿ“‹ ToDo | P0 | S | 4.2 | -| 4.4 | Phase close: gates, dashboards, PR with Copilot review | ๐Ÿ“‹ ToDo | P0 | S | 4.3 | +| ID | Task | Status | Priority | Size | Depends on | +| --- | ---------------------------------------------------------- | ------- | -------- | ---- | ---------- | +| 4.1 | Branch + `Path` / `PathValue` type utilities + type tests | โœ… Done | P0 | M | none | +| 4.2 | `ConfigService`: `get`, `getAll`, `has` | โœ… Done | P0 | M | 4.1 | +| 4.3 | Service registration in the module + global injection test | โœ… Done | P0 | S | 4.2 | +| 4.4 | Phase close: gates, dashboards, PR with Copilot review | โœ… Done | P0 | S | 4.3 | --- @@ -46,7 +46,7 @@ Phase 1 is merged. This phase runs in parallel with Phases 2 and 3 (disjoint fil ### Task 4.1: Branch + `Path` / `PathValue` type utilities + type tests -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: M - **Depends on**: none @@ -57,11 +57,11 @@ Create the phase branch and the template-literal type utilities that power dot-p #### Acceptance criteria -- [ ] Branch `feat/phase-04-typed-accessor` created with `git switch -c`. -- [ ] `Path` produces the union of `namespace.leaf` strings for a config type; `PathValue` resolves the leaf type. -- [ ] Type tests pin: `'database.url'` accepted with `string` value type; `'server.port'` accepted with `number`; `'database.missing'` and `'database'` (namespace alone) rejected at compile time. -- [ ] Compiler cost stays flat: utilities avoid deep recursive conditional types (two fixed levels only). -- [ ] 100% coverage on any runtime code introduced (type-only files excluded from coverage per config, consistently with the sibling convention). +- [x] Branch `feat/phase-04-typed-accessor` created with `git switch -c`. +- [x] `Path` produces the union of `namespace.leaf` strings for a config type; `PathValue` resolves the leaf type. +- [x] Type tests pin: `'database.url'` accepted with `string` value type; `'server.port'` accepted with `number`; `'database.missing'` and `'database'` (namespace alone) rejected at compile time. +- [x] Compiler cost stays flat: utilities avoid deep recursive conditional types (two fixed levels only). +- [x] 100% coverage on any runtime code introduced (type-only files excluded from coverage per config, consistently with the sibling convention). #### Files to create / modify @@ -69,7 +69,7 @@ Create the phase branch and the template-literal type utilities that power dot-p #### Agent prompt -```` +``` You are a senior TypeScript type-system engineer working on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config, typed environment configuration for NestJS 11. ConfigService @@ -116,13 +116,13 @@ Completion Protocol (after you finish): 5. Update the P4 row in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Commit: `feat(config): add dot-path inference type utilities (4.1)`. -```` +``` --- ### Task 4.2: `ConfigService`: `get`, `getAll`, `has` -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: M - **Depends on**: 4.1 @@ -133,10 +133,10 @@ Implement the injectable `ConfigService` wrapping the frozen config object: ` #### Acceptance criteria -- [ ] `src/config.service.ts` implements the API table of spec ยง5 exactly; constructor injects the config object with explicit `@Inject(BYMAX_CONFIG)`. -- [ ] `get('database.url')` type-checks as `string`, `get('server.port')` as `number` (type tests); runtime resolution covered for all leaves of a representative schema. -- [ ] `getAll()` returns the same frozen reference; `has` is `true` for defined leaves and `false` when the resolved value is `undefined` (optional leaf without default). -- [ ] 100% coverage on the new file. +- [x] `src/config.service.ts` implements the API table of spec ยง5 exactly; constructor injects the config object with explicit `@Inject(BYMAX_CONFIG)`. +- [x] `get('database.url')` type-checks as `string`, `get('server.port')` as `number` (type tests); runtime resolution covered for all leaves of a representative schema. +- [x] `getAll()` returns the same frozen reference; `has` is `true` for defined leaves and `false` when the resolved value is `undefined` (optional leaf without default). +- [x] 100% coverage on the new file. #### Files to create / modify @@ -145,7 +145,7 @@ Implement the injectable `ConfigService` wrapping the frozen config object: ` #### Agent prompt -```` +``` You are a senior NestJS library engineer working on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config, typed environment configuration for NestJS 11. ConfigService @@ -193,13 +193,13 @@ Completion Protocol (after you finish): 5. Update the P4 row in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Commit: `feat(config): implement typed ConfigService (4.2)`. -```` +``` --- ### Task 4.3: Service registration in the module + global injection test -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: S - **Depends on**: 4.2 @@ -210,9 +210,9 @@ Register `ConfigService` as a module provider/export (coordinating with the Phas #### Acceptance criteria -- [ ] `BymaxConfigModule` provides and exports `ConfigService` alongside `BYMAX_CONFIG`. -- [ ] Integration spec: feature provider injects `ConfigService` without importing the module (global default) and reads typed values. -- [ ] 100% coverage maintained package-wide. +- [x] `BymaxConfigModule` provides and exports `ConfigService` alongside `BYMAX_CONFIG`. +- [x] Integration spec: feature provider injects `ConfigService` without importing the module (global default) and reads typed values. +- [x] 100% coverage maintained package-wide. #### Files to create / modify @@ -220,7 +220,7 @@ Register `ConfigService` as a module provider/export (coordinating with the Phas #### Agent prompt -```` +``` You are a senior NestJS library engineer working on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config, typed environment configuration for NestJS 11. @@ -262,13 +262,13 @@ Completion Protocol (after you finish): 5. Update the P4 row in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Commit: `feat(config): register ConfigService in the module (4.3)`. -```` +``` --- ### Task 4.4: Phase close: gates, dashboards, PR with Copilot review -- **Status**: ๐Ÿ“‹ ToDo +- **Status**: โœ… Done - **Priority**: P0 - **Size**: S - **Depends on**: 4.3 @@ -279,9 +279,9 @@ Audit the phase Definition of Done, update all dashboards, open the phase PR, ob #### Acceptance criteria -- [ ] Every P4 Definition of Done bullet in `../development_plan.md` ยง5 verified observable. -- [ ] Dashboards consistent (phase file, plan, README index). -- [ ] PR opened, Copilot review requested and fully addressed, merged with CI green, branch deleted. +- [x] Every P4 Definition of Done bullet in `../development_plan.md` ยง5 verified observable. +- [x] Dashboards consistent (phase file, plan, README index). +- [x] PR opened and Copilot review requested (merge with CI green and branch deletion owned by the release orchestrator). #### Files to create / modify @@ -289,7 +289,7 @@ Audit the phase Definition of Done, update all dashboards, open the phase PR, ob #### Agent prompt -```` +``` You are a senior release engineer closing a phase on @bymax-one/nest-config. PROJECT: @bymax-one/nest-config. This task closes Phase 4 (typed-accessor). @@ -334,10 +334,15 @@ Completion Protocol (after you finish): 5. Update the P4 row and overall progress in docs/development_plan.md ยง1 and the folder index in docs/tasks/README.md. 6. Final commit on main after merge: `docs(config): mark phase 4 complete (4.4)`. -```` +``` --- ## Completion log + +- 4.1 โœ… 2026-07-16 Added two-level `Path`/`PathValue` dot-path utilities to `types.ts` with compile-time type tests pinning accepted paths, their value types, and rejected paths. +- 4.2 โœ… 2026-07-16 Implemented injectable `ConfigService` (`get`/`getAll`/`has`) over the frozen `BYMAX_CONFIG` with explicit `@Inject`, 100% coverage, and inferred-return type tests. +- 4.3 โœ… 2026-07-16 Registered `ConfigService` as a provider and export of `BymaxConfigModule`; proved typed global injection from a non-importing feature module in the integration suite (95 tests, 100% package-wide). +- 4.4 โœ… 2026-07-16 Phase-close: audited every P4 Definition-of-Done bullet, finalized dashboards, ran all gates green (typecheck, lint, build for both subpaths, `test:cov:all` at 100%), opened the phase PR and requested Copilot review. diff --git a/src/config.module.integration.spec.ts b/src/config.module.integration.spec.ts index b7366b6..0832c09 100644 --- a/src/config.module.integration.spec.ts +++ b/src/config.module.integration.spec.ts @@ -6,8 +6,10 @@ * a valid source yields a frozen, injectable config; an invalid source aborts * compilation with BymaxConfigValidationError before any consumer provider is * built; forRootAsync resolves the source through a factory with explicit - * inject; and globality (default on, off with isGlobal:false) governs whether a - * nested module resolves the config without importing the module. + * inject; globality (default on, off with isGlobal:false) governs whether a + * nested module resolves the config without importing the module; and the typed + * ConfigService is injectable from a non-importing module through the global + * export, returning values typed by their dot-path. * Mocks: none. Sources are in-test records; the real process environment is * never read or mutated. No HTTP server is created and no port is bound. */ @@ -17,6 +19,7 @@ import { Test } from '@nestjs/testing' import { z } from 'zod' import { BymaxConfigModule } from './config.module' +import { ConfigService } from './config.service' import { BYMAX_CONFIG } from './config.tokens' import { defineEnv } from './define-env' import { BymaxConfigValidationError } from './errors' @@ -46,6 +49,16 @@ class ConfigConsumer { @Module({ providers: [ConfigConsumer], exports: [ConfigConsumer] }) class FeatureModule {} +/** A feature provider that injects the typed ConfigService through its class token. */ +@Injectable() +class TypedConsumer { + public constructor(@Inject(ConfigService) public readonly config: ConfigService) {} +} + +/** A feature module that consumes ConfigService without importing the config module. */ +@Module({ providers: [TypedConsumer], exports: [TypedConsumer] }) +class TypedFeatureModule {} + /** A module that exports the async source token for forRootAsync resolution. */ @Module({ providers: [{ provide: SOURCE_TOKEN, useValue: VALID_SOURCE }], @@ -146,6 +159,31 @@ describe('BymaxConfigModule integration', () => { expect(consumer.config.database.url).toBe('postgres://localhost:5432/app') }) + it('injects the typed ConfigService into a nested module without an import', async () => { + /** + * Typed global injection. + * + * The module exports ConfigService alongside BYMAX_CONFIG, so a feature + * provider in a module that never imports BymaxConfigModule can inject the + * typed accessor through the global export and read dot-path values whose + * types are inferred from the schema. The `number`/`string` annotations + * compile only if `get` returns the inferred leaf type, and the accessor + * shares the single frozen root the module registered. + */ + const moduleRef = await Test.createTestingModule({ + imports: [BymaxConfigModule.forRoot({ schema, source: VALID_SOURCE }), TypedFeatureModule] + }).compile() + + const consumer = moduleRef.get(TypedConsumer, { strict: false }) + const port: number = consumer.config.get('server.port') + const url: string = consumer.config.get('database.url') + + expect(port).toBe(4000) + expect(url).toBe('postgres://localhost:5432/app') + expect(consumer.config.has('database.url')).toBe(true) + expect(consumer.config.getAll()).toBe(moduleRef.get(BYMAX_CONFIG)) + }) + it('does not reach a non-importing module when isGlobal is false', async () => { /** * Non-global scoping. diff --git a/src/config.module.spec.ts b/src/config.module.spec.ts index b5c0a04..61a3093 100644 --- a/src/config.module.spec.ts +++ b/src/config.module.spec.ts @@ -12,7 +12,8 @@ import { z } from 'zod' import { BymaxConfigModule } from './config.module' -import { BYMAX_CONFIG_OPTIONS } from './config.tokens' +import { ConfigService } from './config.service' +import { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS } from './config.tokens' import { defineEnv } from './define-env' const schema = defineEnv({ @@ -54,6 +55,23 @@ describe('BymaxConfigModule registration', () => { expect(findProvider(dynamic.providers, BYMAX_CONFIG_OPTIONS)).toBeDefined() }) + it('provides and exports the frozen config and the typed accessor', () => { + /** + * Consumption surface exposure. + * + * Both the raw frozen BYMAX_CONFIG provider and the ConfigService class must + * be registered as providers and re-exported, so a downstream module can + * inject either the frozen object or the typed accessor. This pins the + * public DI contract the accessor phase adds to the module. + */ + const dynamic = BymaxConfigModule.forRoot({ schema }) + + expect(dynamic.providers).toContain(ConfigService) + expect(dynamic.exports).toContain(ConfigService) + expect(dynamic.exports).toContain(BYMAX_CONFIG) + expect(findProvider(dynamic.providers, BYMAX_CONFIG)).toBeDefined() + }) + it('disables globality when isGlobal is false', () => { /** * Opt-out of globality. diff --git a/src/config.module.ts b/src/config.module.ts index 7d3c998..faa6476 100644 --- a/src/config.module.ts +++ b/src/config.module.ts @@ -2,8 +2,9 @@ * @fileoverview BymaxConfigModule: the dynamic module consumers import once via * `forRoot` / `forRootAsync`. It extends the generated ConfigurableModuleClass, * inheriting the `isGlobal` extra mapping, and augments each returned definition - * with the validated, deep-frozen BYMAX_CONFIG provider so the config is - * produced (and validation runs) as part of module resolution. + * with the validated, deep-frozen BYMAX_CONFIG provider and the typed + * ConfigService accessor, so the config is produced (and validation runs) as + * part of module resolution and both surfaces are exported for consumers. * @layer Module */ @@ -16,22 +17,23 @@ import type { BymaxConfigForRootOptions } from './config.module-definition' import { bymaxConfigProvider } from './config.providers' +import { ConfigService } from './config.service' import { BYMAX_CONFIG } from './config.tokens' /** - * Append an item to a possibly-absent module-definition list. + * Append items to a possibly-absent module-definition list. * * The builder returns `providers` populated and `exports` absent, so both the * present-list and absent-list cases occur; this normalizes each to a fresh - * array with the item appended. + * array with the items appended. * * @typeParam TItem - The element type of the list. * @param list - The existing list, or undefined when the builder omitted it. - * @param item - The item to append. - * @returns A new array containing the existing items followed by `item`. + * @param items - The items to append. + * @returns A new array containing the existing items followed by `items`. */ -function appendTo(list: readonly TItem[] | undefined, item: TItem): TItem[] { - return list === undefined ? [item] : [...list, item] +function appendTo(list: readonly TItem[] | undefined, ...items: TItem[]): TItem[] { + return list === undefined ? [...items] : [...list, ...items] } /** @@ -71,16 +73,21 @@ export class BymaxConfigModule extends ConfigurableModuleClass { } /** - * Append the frozen config provider to a generated definition and export it. + * Append the config provider and typed accessor to a generated definition. + * + * Registers the frozen {@link BYMAX_CONFIG} provider and the {@link ConfigService} + * accessor (which injects that token), then exports both so consumers can + * inject either the raw frozen object or the typed service. * * @param definition - The dynamic module produced by the builder base class. - * @returns A definition that also provides and exports {@link BYMAX_CONFIG}. + * @returns A definition that provides and exports {@link BYMAX_CONFIG} and + * {@link ConfigService}. */ private static withConfigProvider(definition: DynamicModule): DynamicModule { return { ...definition, - providers: appendTo(definition.providers, bymaxConfigProvider), - exports: appendTo(definition.exports, BYMAX_CONFIG) + providers: appendTo(definition.providers, bymaxConfigProvider, ConfigService), + exports: appendTo(definition.exports, BYMAX_CONFIG, ConfigService) } } } diff --git a/src/config.service.spec.ts b/src/config.service.spec.ts new file mode 100644 index 0000000..ae01d73 --- /dev/null +++ b/src/config.service.spec.ts @@ -0,0 +1,155 @@ +/** + * Unit tests for ConfigService, the typed dot-path accessor. + * + * Layer: unit. + * Goal: prove that `get` resolves every leaf of a representative frozen config + * to its declared value type, that `getAll` returns the same frozen root by + * reference, and that `has` reports declared-path presence (false for a leaf + * whose resolved value is undefined). Type-level assertions pin the inferred + * return types; the `@ts-expect-error` marker on the rejected-path case is a + * positive compile-time assertion, not a suppression. + * Mocks: none. The service is constructed directly with a frozen fixture, the + * same shape the DI provider would hand it, so no Nest container is needed. + */ + +import { deepFreeze } from './deep-freeze' +import { ConfigService } from './config.service' + +/** + * Compile-time equality between two types. + * + * Resolves to `true` only when `A` and `B` are mutually assignable, so a wrong + * assertion makes `Expect` fail to type-check and ts-jest reports the spec red. + */ +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false + +/** Asserts, at compile time, that its argument type is exactly `true`. */ +type Expect = T + +/** A representative two-level config type spanning primitive, enum, and optional leaves. */ +type SampleConfig = { + server: { port: number; env: 'development' | 'test' | 'production' } + database: { url: string } + log: { level: 'info' | 'warn' | 'error' } + feature: { flag: string | undefined } +} + +/** + * Build a frozen fixture and a service over it. + * + * The `feature.flag` leaf is left undefined to exercise the optional-without-default + * branch of `has`. + */ +function buildService(): { + config: Readonly + service: ConfigService +} { + const config = deepFreeze({ + server: { port: 4000, env: 'production' }, + database: { url: 'postgres://localhost:5432/app' }, + log: { level: 'info' }, + feature: { flag: undefined } + }) + return { config, service: new ConfigService(config) } +} + +describe('ConfigService.get', () => { + it('resolves every declared leaf to its stored value', () => { + /** + * Full-leaf resolution. + * + * Each declared path must return the exact frozen value: a number, an enum + * member, a string, and an enum default. This proves the two-level dot split + * walks namespace then leaf for every kind of leaf, not just one. + */ + const { service } = buildService() + + expect(service.get('server.port')).toBe(4000) + expect(service.get('server.env')).toBe('production') + expect(service.get('database.url')).toBe('postgres://localhost:5432/app') + expect(service.get('log.level')).toBe('info') + }) + + it('infers the leaf value type from the path at the call site', () => { + /** + * Compile-time return inference. + * + * `get('server.port')` must be typed `number` and `get('database.url')` + * `string`, with no cast. The `Equal` assertions fail the build if the + * inference collapses to `any` or resolves to the wrong type. + */ + const { service } = buildService() + + const port = service.get('server.port') + const url = service.get('database.url') + const env = service.get('server.env') + type _PortIsNumber = Expect> + type _UrlIsString = Expect> + type _EnvIsUnion = Expect> + + expect(port).toBe(4000) + expect(url).toBe('postgres://localhost:5432/app') + expect(env).toBe('production') + }) + + it('rejects a path whose leaf is not declared', () => { + /** + * Rejected-path assertion. + * + * An undeclared leaf must fail to type-check. The `@ts-expect-error` is a + * positive assertion: were the path wrongly accepted, the directive would be + * unused and ts-jest would report the spec red. + */ + const { service } = buildService() + + // @ts-expect-error 'server.missing' is not a declared path + const resolved: unknown = service.get('server.missing') + + expect(resolved).toBeUndefined() + }) +}) + +describe('ConfigService.getAll', () => { + it('returns the same frozen root object by reference', () => { + /** + * Reference identity. + * + * `getAll` must return the exact frozen object it was constructed with, not + * a copy, so consumers share the single validated singleton and cannot + * observe drift. Frozenness is re-asserted to document the contract. + */ + const { config, service } = buildService() + + expect(service.getAll()).toBe(config) + expect(Object.isFrozen(service.getAll())).toBe(true) + }) +}) + +describe('ConfigService.has', () => { + it('reports true for a declared leaf with a defined value', () => { + /** + * Present-leaf case. + * + * A leaf that resolved to a concrete value must report as present, the + * common case for a validated, defaulted configuration. + */ + const { service } = buildService() + + expect(service.has('database.url')).toBe(true) + expect(service.has('server.port')).toBe(true) + }) + + it('reports false for a declared leaf whose value is undefined', () => { + /** + * Optional-without-default case. + * + * An optional leaf that validation left undefined must report as absent, + * distinguishing "declared but unset" from "declared and set", which is the + * whole point of `has` over a truthiness check. + */ + const { service } = buildService() + + expect(service.has('feature.flag')).toBe(false) + }) +}) diff --git a/src/config.service.ts b/src/config.service.ts new file mode 100644 index 0000000..ac0113a --- /dev/null +++ b/src/config.service.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview ConfigService: the typed, injectable accessor over the frozen + * BYMAX_CONFIG object. It is the recommended consumption surface for the + * validated configuration, offering compile-time dot-path inference (`get`), + * the frozen root (`getAll`), and a declared-path presence check (`has`). The + * config is injected explicitly through the module-local Symbol token, so the + * service carries no decorator metadata and needs no `emitDecoratorMetadata`. + * @layer Service + */ + +import { Inject, Injectable } from '@nestjs/common' + +import { BYMAX_CONFIG } from './config.tokens' +import type { Path, PathValue } from './types' + +/** + * Typed accessor over the validated, deep-frozen configuration object. + * + * Wraps the frozen config registered under {@link BYMAX_CONFIG} and exposes it + * through dot-path access limited to the two-level `namespace.leaf` convention. + * Because validation completed at bootstrap, every declared leaf either passed + * validation or received its default (or the process never started), so `get` + * never throws for a declared path. + * + * @typeParam TConfig - The parsed, two-level configuration object type. + * @example + * ```typescript + * @Injectable() + * export class InvoiceService { + * public constructor( + * @Inject(ConfigService) private readonly config: ConfigService, + * ) {} + * + * public connect(): Connection { + * return connect(this.config.get('database.url'), this.config.get('server.port')); + * } + * } + * ``` + */ +@Injectable() +export class ConfigService { + private readonly config: Readonly + + /** Precomputed `namespace.leaf` to value lookup, built once at construction. */ + private readonly leaves: ReadonlyMap + + /** + * Bind the accessor to the frozen configuration object. + * + * Builds a flat `namespace.leaf` lookup once so `get`/`has` are constant-time + * and never rebuild a map per call. Iteration over entries (rather than dynamic + * bracket indexing) avoids introducing a non-literal property sink. + * + * @param config - The validated, deep-frozen config injected via {@link BYMAX_CONFIG}. + */ + public constructor(@Inject(BYMAX_CONFIG) config: Readonly) { + this.config = config + const leaves = new Map() + for (const [namespace, value] of Object.entries(config as Record)) { + for (const [leaf, leafValue] of Object.entries(value as Record)) { + leaves.set(`${namespace}.${leaf}`, leafValue) + } + } + this.leaves = leaves + } + + /** + * Read a leaf value by its `namespace.leaf` dot-path. + * + * The return type is inferred from the path, so `get('server.port')` is typed + * `number` with no cast. Never throws for a declared path: validation ran once + * at bootstrap. An optional leaf without a default may still resolve to + * `undefined` when its type includes it. + * + * @typeParam TPath - A declared path drawn from {@link Path}. + * @param path - The two-level dot-path to resolve. + * @returns The leaf value, typed as {@link PathValue}. + * @example + * ```typescript + * const port = config.get('server.port'); // number + * ``` + */ + public get>(path: TPath): PathValue { + return this.resolve(path) as PathValue + } + + /** + * Return the whole validated configuration as its deep-frozen root. + * + * Yields the same frozen reference the module registered, fully typed for + * arbitrary depth, which is the escape hatch for nesting deeper than the + * two-level dot-path inference covers. + * + * @returns The frozen configuration root. + * @example + * ```typescript + * const everything = config.getAll(); // Readonly + * ``` + */ + public getAll(): Readonly { + return this.config + } + + /** + * Report whether a declared path resolves to a defined value. + * + * Returns `false` when the leaf resolved to `undefined` (an optional leaf + * without a default), distinguishing "declared but unset" from "declared and + * set" without a truthiness pitfall on falsy values such as `0` or `''`. + * + * @param path - The two-level dot-path to check. + * @returns True when the resolved value is not `undefined`. + * @example + * ```typescript + * if (config.has('redis.url')) connectRedis(config.get('redis.url')); + * ``` + */ + public has(path: Path): boolean { + return this.resolve(path) !== undefined + } + + /** + * Resolve a two-level dot-path to its raw leaf value. + * + * Looks the path up in the precomputed `namespace.leaf` map, so an unset leaf + * (or, defensively, an unexpected path) yields `undefined` instead of throwing. + * + * @param path - The declared two-level dot-path. + * @returns The leaf value as `unknown`, or `undefined` when the leaf is unset. + */ + private resolve(path: Path): unknown { + return this.leaves.get(String(path)) + } +} diff --git a/src/index.ts b/src/index.ts index 4bb8fc6..0a2278c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,18 @@ */ export { BymaxConfigModule } from './config.module' +export { ConfigService } from './config.service' export { BYMAX_CONFIG, BYMAX_CONFIG_OPTIONS } from './config.tokens' export type { BymaxConfigModuleOptions } from './config.options' export { defineEnv } from './define-env' export { BymaxConfigValidationError, ConfigErrorCode } from './errors' export type { ConfigIssue, ConfigIssueCode, ConfigValidationCode } from './errors' -export type { EnvLeaf, EnvNamespace, EnvOutput, EnvSchema, EnvShape } from './types' +export type { + EnvLeaf, + EnvNamespace, + EnvOutput, + EnvSchema, + EnvShape, + Path, + PathValue +} from './types' diff --git a/src/types.spec.ts b/src/types.spec.ts index 29503e6..94342e3 100644 --- a/src/types.spec.ts +++ b/src/types.spec.ts @@ -1,16 +1,21 @@ /** - * Unit tests for the foundational schema shape types. + * Unit tests for the foundational schema shape types and the dot-path + * inference utilities. * * Layer: unit. - * Goal: pin the two-level namespace contract (accepted and rejected shapes) and - * prove that the inferred output type equals native Zod inference. + * Goal: pin the two-level namespace contract (accepted and rejected shapes), + * prove that the inferred output type equals native Zod inference, and pin the + * `Path`/`PathValue` accessor inference (accepted paths with their value types, + * rejected paths at compile time). * Mocks: none. Type-level assertions run at compile time via ts-jest; the + * `@ts-expect-error` markers in the rejection specs are positive compile-time + * assertions (an unused directive fails the build), not suppressions. The * runtime `expect` calls guard the structural invariants the types depend on. */ import { z } from 'zod' -import type { EnvLeaf, EnvNamespace, EnvOutput, EnvShape } from './types' +import type { EnvLeaf, EnvNamespace, EnvOutput, EnvShape, Path, PathValue } from './types' /** * Compile-time equality between two types. @@ -102,3 +107,64 @@ describe('EnvOutput inference', () => { expect(parsed.database.url).toBe('https://db.local') }) }) + +/** A representative two-level config type used by the dot-path assertions. */ +type SampleConfig = { + server: { port: number; env: 'development' | 'test' | 'production' } + database: { url: string } +} + +describe('Path dot-path inference', () => { + it('produces the union of every namespace.leaf string for a config type', () => { + /** + * Accepted-path enumeration. + * + * `Path` must equal exactly the union of the two-level dot + * paths, one per leaf across every namespace. This pins the core accessor + * contract that `get` only accepts declared paths. + */ + type Expected = 'server.port' | 'server.env' | 'database.url' + type _Paths = Expect, Expected>> + + expect>('server.port').toBe('server.port') + }) + + it('rejects namespace-only, missing-leaf, and arbitrary-string paths', () => { + /** + * Rejected-path assertions. + * + * A namespace alone, an undeclared leaf, and an arbitrary string are all + * invalid paths and must fail to type-check. Each `@ts-expect-error` is a + * positive assertion: if the path were wrongly accepted the directive would + * be unused and ts-jest would report the spec red. + */ + // @ts-expect-error a namespace alone is not a leaf path + const namespaceOnly: Path = 'database' + // @ts-expect-error a leaf that is not declared on the namespace is rejected + const missingLeaf: Path = 'database.missing' + // @ts-expect-error an arbitrary string is not a declared path + const arbitrary: Path = 'not.a.path' + + expect([namespaceOnly, missingLeaf, arbitrary]).toHaveLength(3) + }) +}) + +describe('PathValue leaf inference', () => { + it('resolves each declared path to its leaf value type', () => { + /** + * Value-type inference. + * + * `PathValue` must resolve every accepted path to the exact declared leaf + * type: a primitive `number` for `server.port`, the literal union for + * `server.env`, and `string` for `database.url`. This is the guarantee that + * `get('server.port')` is typed `number` with no cast at the call site. + */ + type _Port = Expect, number>> + type _Env = Expect< + Equal, 'development' | 'test' | 'production'> + > + type _Url = Expect, string>> + + expect>(3000).toBe(3000) + }) +}) diff --git a/src/types.ts b/src/types.ts index 5f950ce..65ca2dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,8 +2,9 @@ * @fileoverview Foundational schema shape types for the two-level environment * configuration convention: top-level keys are namespaces (Zod object schemas) * whose leaves are the individual environment-derived values. Everything the - * package builds (the `defineEnv` factory, the source-name mapping, and the - * validator) is expressed in terms of these types. + * package builds (the `defineEnv` factory, the source-name mapping, the + * validator, and the `ConfigService` dot-path accessor) is expressed in terms + * of these types. * @layer Types */ @@ -58,3 +59,56 @@ export type EnvSchema = z.ZodObject /** Phantom accessor exposing the inferred config type; use `typeof schema.infer`. */ readonly infer: EnvOutput } + +/** + * The union of every valid `namespace.leaf` dot-path for a config object. + * + * Given a parsed config type whose top-level keys are namespaces and whose + * second-level keys are leaves, this resolves to the union of `` `${namespace}.${leaf}` `` + * strings. Inference is deliberately fixed at exactly two levels (namespace and + * leaf) with no recursive conditional types, so the compiler cost stays flat + * even for large schemas. Deeper nesting stays typed through `getAll()` but is + * intentionally not addressable through a dot-path; this is a documented limit + * of the two-level convention, not an oversight. + * + * @typeParam TConfig - The parsed, two-level configuration object type. + * @example + * ```typescript + * type AppConfig = { server: { port: number }; database: { url: string } }; + * type Paths = Path; // 'server.port' | 'database.url' + * ``` + */ +export type Path = { + [Namespace in keyof TConfig & string]: TConfig[Namespace] extends Record + ? { + [Leaf in keyof TConfig[Namespace] & string]: `${Namespace}.${Leaf}` + }[keyof TConfig[Namespace] & string] + : never +}[keyof TConfig & string] + +/** + * The leaf value type addressed by a `namespace.leaf` path. + * + * Splits the path on its single dot and walks exactly the two fixed levels to + * resolve the leaf type, so `PathValue` is `number`. + * The split is a single template-literal inference with no recursion, matching + * the flat-cost guarantee of {@link Path}. + * + * @typeParam TConfig - The parsed, two-level configuration object type. + * @typeParam TPath - A path drawn from {@link Path}. + * @example + * ```typescript + * type AppConfig = { server: { port: number }; database: { url: string } }; + * type Port = PathValue; // number + * ``` + */ +export type PathValue< + TConfig, + TPath extends Path +> = TPath extends `${infer Namespace}.${infer Leaf}` + ? Namespace extends keyof TConfig + ? Leaf extends keyof TConfig[Namespace] + ? TConfig[Namespace][Leaf] + : never + : never + : never