Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/development_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/tasks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ Tasks live **one file per phase** in this folder (`phase-NN-<slug>.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 |

---

Expand Down
73 changes: 39 additions & 34 deletions docs/tasks/phase-04-typed-accessor.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -33,20 +33,20 @@ 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 |

---

## Tasks

### Task 4.1: Branch + `Path` / `PathValue` type utilities + type tests

- **Status**: 📋 ToDo
- **Status**: ✅ Done
- **Priority**: P0
- **Size**: M
- **Depends on**: none
Expand All @@ -57,19 +57,19 @@ 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<T>` produces the union of `namespace.leaf` strings for a config type; `PathValue<T, P>` 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<T>` produces the union of `namespace.leaf` strings for a config type; `PathValue<T, P>` 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

- `src/types.ts` (extend) or `src/path-types.ts` per layout, plus type-test spec

#### 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
Expand Down Expand Up @@ -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
Expand All @@ -133,10 +133,10 @@ Implement the injectable `ConfigService<T>` 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

Expand All @@ -145,7 +145,7 @@ Implement the injectable `ConfigService<T>` 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
Expand Down Expand Up @@ -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
Expand All @@ -210,17 +210,17 @@ 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<AppConfig>` 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<AppConfig>` without importing the module (global default) and reads typed values.
- [x] 100% coverage maintained package-wide.

#### Files to create / modify

- `src/config.module.ts` (provider registration), integration spec

#### 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.
Expand Down Expand Up @@ -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
Expand All @@ -279,17 +279,17 @@ 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

- `docs/tasks/phase-04-typed-accessor.md`, `docs/development_plan.md`, `docs/tasks/README.md`

#### 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).
Expand Down Expand Up @@ -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

<!-- Append one line per completed task: - <id> ✅ YYYY-MM-DD <summary> -->

- 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<T>` (`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.
42 changes: 40 additions & 2 deletions src/config.module.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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'
Expand Down Expand Up @@ -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<AppConfig>) {}
}

/** 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 }],
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion src/config.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 19 additions & 12 deletions src/config.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/

Expand All @@ -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<TItem>(list: readonly TItem[] | undefined, item: TItem): TItem[] {
return list === undefined ? [item] : [...list, item]
function appendTo<TItem>(list: readonly TItem[] | undefined, ...items: TItem[]): TItem[] {
return list === undefined ? [...items] : [...list, ...items]
}

/**
Expand Down Expand Up @@ -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)
}
}
}
Loading
Loading