@dudousxd/nestjs-codegen 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/extension/index.ts","../../src/extension/types.ts"],"sourcesContent":["export { defineExtension, requestShape } from './types.js';\nexport type {\n ApiClientLayer,\n ApiHeaderContribution,\n ApiModuleDeps,\n CodegenExtension,\n EmittedFile,\n ExtensionContext,\n LeafModel,\n RequestModel,\n RequestShape,\n} from './types.js';\n","import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, a filter-search route (has `filterFields`), or an\n * `@AsQuery()`-marked route — even when the method is POST. Client layers\n * use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n // A route counts as a read when it's a GET, a filter-search POST (carries\n // `filterFields`), or explicitly opted in via `@AsQuery()` (`cs.asQuery`) —\n // e.g. a POST whose semantics are a read (a query-shaped payload).\n const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;\n // `multipart` implies a body even when the route has no `@Body()` at all (a bare\n // `@UploadedFile()` route): the type block intersects `multipartBody` into the body\n // type, so the runtime leaf must accept and forward it too — otherwise the ApiRouter\n // type promises `body: { file: File | Blob }` while the generated call silently\n // drops the file.\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never') || !!cs?.multipart;\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACkLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAI7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc,UAAU,CAAC,CAAC,IAAI;AAM7D,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS,WAAY,CAAC,CAAC,IAAI;AACpF,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/extension/index.ts","../../src/extension/types.ts"],"sourcesContent":["export { defineExtension, requestShape } from './types.js';\nexport type {\n ApiClientLayer,\n ApiHeaderContribution,\n ApiModuleDeps,\n CodegenExtension,\n EmittedFile,\n ExtensionContext,\n LeafModel,\n RequestModel,\n RequestShape,\n} from './types.js';\n","import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, a filter-search route (has `filterFields`), or an\n * `@AsQuery()`-marked route — even when the method is POST. Client layers\n * use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n /**\n * Runtime filter-fields literal-array expression, e.g. `[\"id\", \"name\"] as const`,\n * or `undefined` for a route with no filter. Derived from the same discovered\n * field list as the type-level `filterFields` union, so the emitted value stays\n * in lockstep with the type.\n */\n filterFieldsExpr?: string | undefined;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n // A route counts as a read when it's a GET, a filter-search POST (carries\n // `filterFields`), or explicitly opted in via `@AsQuery()` (`cs.asQuery`) —\n // e.g. a POST whose semantics are a read (a query-shaped payload).\n const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;\n // `multipart` implies a body even when the route has no `@Body()` at all (a bare\n // `@UploadedFile()` route): the type block intersects `multipartBody` into the body\n // type, so the runtime leaf must accept and forward it too — otherwise the ApiRouter\n // type promises `body: { file: File | Blob }` while the generated call silently\n // drops the file.\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never') || !!cs?.multipart;\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACyLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAI7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc,UAAU,CAAC,CAAC,IAAI;AAM7D,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS,WAAY,CAAC,CAAC,IAAI;AACpF,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
@@ -1,2 +1,2 @@
1
- export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-DT8SgPxp.cjs';
1
+ export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-CjIDPMsV.cjs';
2
2
  import 'ts-morph';
@@ -1,2 +1,2 @@
1
- export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-DT8SgPxp.js';
1
+ export { m as ApiClientLayer, n as ApiHeaderContribution, o as ApiModuleDeps, C as CodegenExtension, p as EmittedFile, E as ExtensionContext, L as LeafModel, q as RequestModel, s as RequestShape, t as defineExtension, u as requestShape } from '../index-CjIDPMsV.js';
2
2
  import 'ts-morph';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/extension/types.ts"],"sourcesContent":["import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, a filter-search route (has `filterFields`), or an\n * `@AsQuery()`-marked route — even when the method is POST. Client layers\n * use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n // A route counts as a read when it's a GET, a filter-search POST (carries\n // `filterFields`), or explicitly opted in via `@AsQuery()` (`cs.asQuery`) —\n // e.g. a POST whose semantics are a read (a query-shaped payload).\n const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;\n // `multipart` implies a body even when the route has no `@Body()` at all (a bare\n // `@UploadedFile()` route): the type block intersects `multipartBody` into the body\n // type, so the runtime leaf must accept and forward it too — otherwise the ApiRouter\n // type promises `body: { file: File | Blob }` while the generated call silently\n // drops the file.\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never') || !!cs?.multipart;\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";AAkLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAI7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc,UAAU,CAAC,CAAC,IAAI;AAM7D,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS,WAAY,CAAC,CAAC,IAAI;AACpF,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../../src/extension/types.ts"],"sourcesContent":["import type { Project } from 'ts-morph';\nimport type { ResolvedConfig } from '../config/types.js';\nimport type { RouteDescriptor } from '../discovery/types.js';\n\n/**\n * The published, versioned extension contract for `@dudousxd/nestjs-codegen`.\n *\n * Extensions are **build-time** objects (usually returned by a factory so they can take\n * options) registered explicitly via `forRoot({ extensions: [...] })`. The host runs them\n * around the core discovery → IR → emit pipeline.\n *\n * Hooks split into **multi** (every extension runs; results accumulate or chain) and\n * **single-slot** (at most one extension may claim it — two claimers is a hard error).\n *\n * @remarks Semver 0.x — the shape may change until 1.0. Out-of-repo extensions should pin\n * a compatible `@dudousxd/nestjs-codegen` peer range.\n */\nexport interface CodegenExtension {\n /** Unique id. Used in conflict/collision errors and for deterministic ordering. */\n name: string;\n\n // ── multi hooks (every extension runs) ────────────────────────────────────\n\n /**\n * Mutate/augment the route IR before emit. Runs in registration order, chained\n * (each extension sees the previous one's output). Return the new array, or mutate\n * in place and return void. Example: the filter extension attaches `filterFields` to\n * matching routes here.\n */\n transformRoutes?(\n routes: RouteDescriptor[],\n ctx: ExtensionContext,\n ): RouteDescriptor[] | undefined | Promise<RouteDescriptor[] | undefined>;\n\n /**\n * Contribute extra output files (additive). Paths are relative to `outDir`; a path\n * claimed by two extensions is a hard error. Example: the Inertia extension does its\n * own page discovery via `ctx.project()` and emits `pages.d.ts` + `components.json`.\n */\n emitFiles?(ctx: ExtensionContext): EmittedFile[] | Promise<EmittedFile[]>;\n\n /**\n * Contribute top-level code to `api.ts` (imports + statements). Runs in registration\n * order; imports are deduped by the host. Example: the Inertia extension adds\n * `import { router } from '@inertiajs/react'` and the `navigate()` helper.\n */\n apiHeader?(ctx: ExtensionContext): ApiHeaderContribution | undefined;\n\n /**\n * Add named members to a **handle** leaf. Only runs when a client layer is active\n * (i.e. the leaf is a handle, not a bare callable). Member-name collisions across\n * extensions are a hard error. Example: the filter extension adds `filterQuery` to\n * leaves whose route carries `filterFields`.\n */\n apiMembers?(leaf: LeafModel, ctx: ExtensionContext): Record<string, string> | undefined;\n\n // ── single-slot hooks (at most one extension) ─────────────────────────────\n\n /**\n * Claims **what** a leaf returns and **how** it issues its request. At most one extension\n * may claim it. When unset, a leaf is a bare awaitable callable backed by the neutral\n * fetcher. Example: the TanStack extension wraps each leaf into a handle exposing\n * `{ fetch, queryKey, queryOptions | mutationOptions }`, composing with the fetcher\n * request the host passes in.\n */\n apiClientLayer?: ApiClientLayer;\n}\n\n/** Shared, read-only context handed to every extension hook. */\nexport interface ExtensionContext {\n cwd: string;\n outDir: string;\n routes: readonly RouteDescriptor[];\n config: ResolvedConfig;\n /** Lazily-created shared ts-morph Project for AST work (pages, custom decorators). */\n project(): Project;\n}\n\n/** A file contributed by an extension's `emitFiles` hook. */\nexport interface EmittedFile {\n /** Path relative to `outDir`. A collision across extensions throws. */\n path: string;\n contents: string;\n}\n\n/** Top-level `api.ts` contributions from an extension's `apiHeader` hook. */\nexport interface ApiHeaderContribution {\n /** Raw import lines (e.g. `import { router } from '@inertiajs/react';`), deduped by the host. */\n imports?: string[];\n /** Top-level statements appended after the api factory (e.g. the `navigate()` helper). */\n statements?: string[];\n}\n\n/**\n * The neutral, per-endpoint request model the host builds for each leaf before any\n * transport/layer runs. Extensions read this to render their output.\n */\nexport interface RequestModel {\n /** Dot-path route name, e.g. `users.show`. */\n routeName: string;\n method: 'get' | 'post' | 'put' | 'patch' | 'delete';\n isGet: boolean;\n /** True for reads: a GET, a filter-search route (has `filterFields`), or an\n * `@AsQuery()`-marked route — even when the method is POST. Client layers\n * use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n hasParams: boolean;\n hasBody: boolean;\n /** Type of the leaf's `input` arg, e.g. `{ params: ...; query?: ... }` or `Record<string, never>`. */\n inputType: string;\n /** URL expression, e.g. `route('users.show', input?.params) || '/api/users/:id'`. */\n urlExpr: string;\n /** Request-options expression, e.g. `{ query: ... }` or `{ body: input?.body }`. */\n optsExpr: string;\n /** Response type access, e.g. `ApiRouter['users']['show']['response']`. */\n responseType: string;\n /** Stable query-key expression, e.g. `[\"users.show\", input] as const`. */\n queryKeyExpr: string;\n /**\n * Runtime filter-fields literal-array expression, e.g. `[\"id\", \"name\"] as const`,\n * or `undefined` for a route with no filter. Derived from the same discovered\n * field list as the type-level `filterFields` union, so the emitted value stays\n * in lockstep with the type.\n */\n filterFieldsExpr?: string | undefined;\n}\n\n/**\n * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.\n * `requestExpr` is the host's neutral fetcher request; `members`, when present, flips the\n * leaf from a bare callable to a handle.\n */\nexport interface LeafModel {\n route: RouteDescriptor;\n request: RequestModel;\n /** The expression that issues the request (the host's neutral fetcher call). */\n requestExpr: string;\n /** When present, the leaf renders as a handle exposing these members (ordered). */\n members?: Record<string, string>;\n}\n\n/**\n * Top-level `api.ts` imports a client layer depends on. A function of the context so it can\n * be route-aware (e.g. only import `mutationOptions` when a mutation exists). Imports are\n * deduped by the host across all extensions.\n */\nexport interface ApiModuleDeps {\n /** Raw import lines (e.g. `import { queryOptions as _q } from '@tanstack/react-query';`). */\n imports?(ctx: ExtensionContext): string[];\n}\n\n/** Single-slot: decides what a leaf returns (the handle members). */\nexport interface ApiClientLayer extends ApiModuleDeps {\n name: string;\n /**\n * Given the request expression (from the transport) and the leaf, return the handle's\n * members as an ordered `name → value` map (value is the expression after `name: `).\n * Returning members flips the leaf from a bare callable to a handle.\n */\n buildMembers(requestExpr: string, leaf: LeafModel, ctx: ExtensionContext): Record<string, string>;\n}\n\n/**\n * The four request-shape flags derived from a route's method + contract. Computed in ONE\n * place ({@link requestShape}) and read by both the host emitter and client-layer\n * extensions, so the \"filter-search POST counts as a read\" rule is encoded exactly once.\n */\nexport interface RequestShape {\n /** The route is a `GET`. */\n isGet: boolean;\n /** True for reads: a GET, or a filter-search route (carries `filterFields`) even when POST.\n * Client layers use this (not `isGet`) to decide query vs mutation helpers. */\n isQuery: boolean;\n /** The route carries a body contract (a mutation payload). */\n hasBody: boolean;\n /** The route can take a query string — always for GET; a mutation may too (query + body). */\n hasQuery: boolean;\n}\n\n/**\n * Compute the {@link RequestShape} flags for a route from its method and contract. This is\n * the SINGLE source of truth for these flags — `buildRequestModel`, the TanStack layer's\n * `imports()`, and any other reader must call this rather than re-deriving. The\n * \"filter-search POST counts as a read\" rule lives here and nowhere else.\n */\nexport function requestShape(route: RouteDescriptor): RequestShape {\n const cs = route.contract?.contractSource;\n const isGet = route.method.toUpperCase() === 'GET';\n // A route counts as a read when it's a GET, a filter-search POST (carries\n // `filterFields`), or explicitly opted in via `@AsQuery()` (`cs.asQuery`) —\n // e.g. a POST whose semantics are a read (a query-shaped payload).\n const isQuery = isGet || !!cs?.filterFields?.length || !!cs?.asQuery;\n // `multipart` implies a body even when the route has no `@Body()` at all (a bare\n // `@UploadedFile()` route): the type block intersects `multipartBody` into the body\n // type, so the runtime leaf must accept and forward it too — otherwise the ApiRouter\n // type promises `body: { file: File | Blob }` while the generated call silently\n // drops the file.\n const hasBody = !!cs?.bodyRef || (cs?.body != null && cs.body !== 'never') || !!cs?.multipart;\n const hasQuery = isGet || !!cs?.queryRef || (cs?.query != null && cs.query !== 'never');\n return { isGet, isQuery, hasBody, hasQuery };\n}\n\n/** Identity helper for authoring extensions with full type inference. */\nexport function defineExtension(ext: CodegenExtension): CodegenExtension {\n return ext;\n}\n"],"mappings":";AAyLO,SAAS,aAAa,OAAsC;AACjE,QAAM,KAAK,MAAM,UAAU;AAC3B,QAAM,QAAQ,MAAM,OAAO,YAAY,MAAM;AAI7C,QAAM,UAAU,SAAS,CAAC,CAAC,IAAI,cAAc,UAAU,CAAC,CAAC,IAAI;AAM7D,QAAM,UAAU,CAAC,CAAC,IAAI,WAAY,IAAI,QAAQ,QAAQ,GAAG,SAAS,WAAY,CAAC,CAAC,IAAI;AACpF,QAAM,WAAW,SAAS,CAAC,CAAC,IAAI,YAAa,IAAI,SAAS,QAAQ,GAAG,UAAU;AAC/E,SAAO,EAAE,OAAO,SAAS,SAAS,SAAS;AAC7C;AAGO,SAAS,gBAAgB,KAAyC;AACvE,SAAO;AACT;","names":[]}
@@ -655,6 +655,13 @@ interface RequestModel {
655
655
  responseType: string;
656
656
  /** Stable query-key expression, e.g. `["users.show", input] as const`. */
657
657
  queryKeyExpr: string;
658
+ /**
659
+ * Runtime filter-fields literal-array expression, e.g. `["id", "name"] as const`,
660
+ * or `undefined` for a route with no filter. Derived from the same discovered
661
+ * field list as the type-level `filterFields` union, so the emitted value stays
662
+ * in lockstep with the type.
663
+ */
664
+ filterFieldsExpr?: string | undefined;
658
665
  }
659
666
  /**
660
667
  * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.
@@ -655,6 +655,13 @@ interface RequestModel {
655
655
  responseType: string;
656
656
  /** Stable query-key expression, e.g. `["users.show", input] as const`. */
657
657
  queryKeyExpr: string;
658
+ /**
659
+ * Runtime filter-fields literal-array expression, e.g. `["id", "name"] as const`,
660
+ * or `undefined` for a route with no filter. Derived from the same discovered
661
+ * field list as the type-level `filterFields` union, so the emitted value stays
662
+ * in lockstep with the type.
663
+ */
664
+ filterFieldsExpr?: string | undefined;
658
665
  }
659
666
  /**
660
667
  * Per-leaf model passed through the api.ts pipeline: layer → member contributors → render.
package/dist/index.cjs CHANGED
@@ -807,6 +807,9 @@ function buildErrorType(c) {
807
807
  }
808
808
  return c.contractSource.error ?? "unknown";
809
809
  }
810
+ function filterFieldLiterals(fields) {
811
+ return fields?.length ? fields.map((f) => JSON.stringify(f)) : [];
812
+ }
810
813
  function emitRouterTypeBlock(tree, indent, outDir, serialization) {
811
814
  const pad = " ".repeat(indent);
812
815
  const lines = [];
@@ -833,7 +836,8 @@ function emitRouterTypeBlock(tree, indent, outDir, serialization) {
833
836
  const params = buildParamsType(c.params);
834
837
  const safeMethod = JSON.stringify(method);
835
838
  const safeUrl = JSON.stringify(c.path);
836
- const filterFields = c.contractSource.filterFields?.length ? c.contractSource.filterFields.map((f) => JSON.stringify(f)).join(" | ") : "never";
839
+ const filterLiterals = filterFieldLiterals(c.contractSource.filterFields);
840
+ const filterFields = filterLiterals.length ? filterLiterals.join(" | ") : "never";
837
841
  const stream = c.contractSource.stream ? "true" : "false";
838
842
  const binary = c.contractSource.binaryResponse ? "true" : "false";
839
843
  lines.push(
@@ -873,6 +877,7 @@ function buildRequestModel(c) {
873
877
  const TA = buildRouterTypeAccess(c.name);
874
878
  const withParams = hasPathParams(c.params);
875
879
  const { isGet, isQuery, hasBody, hasQuery } = requestShape(c.route);
880
+ const filterLiterals = filterFieldLiterals(c.contractSource.filterFields);
876
881
  const fields = [];
877
882
  if (withParams) fields.push(`params: ${TA}['params']`);
878
883
  if (hasQuery) fields.push(`query?: ${TA}['query']`);
@@ -902,7 +907,12 @@ function buildRequestModel(c) {
902
907
  // (`[name]` rather than `[name, undefined]`) so the bare `.queryKey()` is a
903
908
  // clean prefix that partial-matches every parametrized variant — making it
904
909
  // directly usable for `invalidateQueries`.
905
- queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`
910
+ queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`,
911
+ // Runtime counterpart to the type-level `filterFields` union: the same
912
+ // discovered field list, emitted as a literal `[...] as const` so apps can
913
+ // validate a dynamic/user-supplied field string with `isFilterField(...)`
914
+ // instead of casting. Omitted for routes with no filter.
915
+ ...filterLiterals.length ? { filterFieldsExpr: `[${filterLiterals.join(", ")}] as const` } : {}
906
916
  };
907
917
  }
908
918
  function renderFetcherRequest(req, binaryResponse) {
@@ -937,9 +947,24 @@ function emitReqHelper() {
937
947
  ""
938
948
  ];
939
949
  }
950
+ function emitFilterFieldGuard() {
951
+ return [
952
+ "/** Runtime guard: narrows `value` to one of the leaf's `filterFields` (a `readonly K[] as const`), so a dynamic field string can be passed to `.where()` without a cast. */",
953
+ "export function isFilterField<const K extends string>(",
954
+ " fields: readonly K[],",
955
+ " value: string,",
956
+ "): value is K {",
957
+ " return (fields as readonly string[]).includes(value);",
958
+ "}",
959
+ ""
960
+ ];
961
+ }
940
962
  function renderLeaf(pad, objKey, req, requestExpr, members, streamExpr) {
941
963
  const lines = [`${pad}${objKey}: (input?: ${req.inputType}) => ({`];
942
964
  lines.push(`${pad} ...__req<${req.responseType}>(() => ${requestExpr}),`);
965
+ if (req.filterFieldsExpr) {
966
+ lines.push(`${pad} filterFields: ${req.filterFieldsExpr},`);
967
+ }
943
968
  if (streamExpr) {
944
969
  lines.push(`${pad} stream: () => ${streamExpr},`);
945
970
  }
@@ -1211,6 +1236,9 @@ function buildApiFile(routes, outDir, opts = {}) {
1211
1236
  lines.push("};");
1212
1237
  lines.push("");
1213
1238
  lines.push(...emitReqHelper());
1239
+ if (contracted.some((r) => r.contract?.contractSource.filterFields?.length)) {
1240
+ lines.push(...emitFilterFieldGuard());
1241
+ }
1214
1242
  lines.push("export function createApi(fetcher: Fetcher) {");
1215
1243
  lines.push(" return {");
1216
1244
  lines.push(
@@ -2220,19 +2248,41 @@ function isManifestShape(value) {
2220
2248
  if (typeof candidate.hash !== "string") return false;
2221
2249
  if (candidate.entryPoint !== void 0 && !isEntryPoint(candidate.entryPoint)) return false;
2222
2250
  if (candidate.configHash !== void 0 && typeof candidate.configHash !== "string") return false;
2251
+ if (candidate.configKeyHashes !== void 0 && !isStringRecord(candidate.configKeyHashes)) {
2252
+ return false;
2253
+ }
2223
2254
  if (!Array.isArray(candidate.files)) return false;
2224
2255
  return candidate.files.every((entry) => typeof entry === "string");
2225
2256
  }
2257
+ function isStringRecord(value) {
2258
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
2259
+ return Object.values(value).every((entry) => typeof entry === "string");
2260
+ }
2226
2261
  function serializeConfig(config) {
2262
+ return serializeConfigValue(config, `unserializable:${config.codegen.outDir}`);
2263
+ }
2264
+ function serializeConfigValue(value, unserializableMarker) {
2227
2265
  try {
2228
- return JSON.stringify(config, (_key, value) => {
2229
- if (typeof value === "function") return `[fn:${value.name}]${value.toString()}`;
2230
- return value;
2266
+ return JSON.stringify(value, (_key, entry) => {
2267
+ if (typeof entry === "function") return `[fn:${entry.name}]`;
2268
+ return entry;
2231
2269
  });
2232
2270
  } catch {
2233
- return `unserializable:${config.codegen.outDir}:${config.contracts.glob}`;
2271
+ return unserializableMarker;
2234
2272
  }
2235
2273
  }
2274
+ function computeConfigKeyHashes(config) {
2275
+ const hashes = {};
2276
+ for (const [key, value] of Object.entries(config)) {
2277
+ if (value === void 0) continue;
2278
+ hashes[key] = (0, import_node_crypto.createHash)("sha256").update(serializeConfigValue(value, `unserializable:${key}`)).digest("hex");
2279
+ }
2280
+ return hashes;
2281
+ }
2282
+ function diffConfigKeyHashes(previous, current) {
2283
+ const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(current)]);
2284
+ return [...keys].filter((key) => previous[key] !== current[key]).sort();
2285
+ }
2236
2286
  async function discoverInputFiles(config) {
2237
2287
  const globs = [config.contracts.glob, config.forms.watch];
2238
2288
  if (config.pages) globs.push(config.pages.glob);
@@ -2267,6 +2317,7 @@ async function readManifest(outDir) {
2267
2317
  hash: parsed.hash,
2268
2318
  ...parsed.entryPoint ? { entryPoint: parsed.entryPoint } : {},
2269
2319
  ...parsed.configHash ? { configHash: parsed.configHash } : {},
2320
+ ...parsed.configKeyHashes ? { configKeyHashes: parsed.configKeyHashes } : {},
2270
2321
  files: parsed.files
2271
2322
  };
2272
2323
  } catch {
@@ -2320,8 +2371,9 @@ function debugWarn(message) {
2320
2371
  }
2321
2372
 
2322
2373
  // src/generate.ts
2323
- function driftGuardMessage(outDir, previousEntryPoint, currentEntryPoint) {
2324
- return `[nestjs-codegen] Config drift detected in "${outDir}": the last generate ran from the "${previousEntryPoint}" entry point, this run is from the "${currentEntryPoint}" entry point, and their resolved configs differ (e.g. \`serialization: "json"\` vs \`"superjson"\`). Both entry points must read the SAME config \u2014 export a shared config object (e.g. codegen.config.ts) and import it from BOTH nestjs-codegen.config.ts (CLI) and NestjsCodegenModule.forRoot() (Nest module), or set \`driftGuard: false\` on either config to opt out of this check.`;
2374
+ function driftGuardMessage(outDir, previousEntryPoint, currentEntryPoint, differingKeys) {
2375
+ const differ = differingKeys.length > 0 ? `their resolved configs differ at: ${differingKeys.map((key) => `\`${key}\``).join(", ")}` : "their resolved configs differ (re-run after this generate records per-key hashes to see which keys)";
2376
+ return `[nestjs-codegen] Config drift detected in "${outDir}": the last generate ran from the "${previousEntryPoint}" entry point, this run is from the "${currentEntryPoint}" entry point, and ${differ}. Both entry points must read the SAME config \u2014 export a shared config object (e.g. codegen.config.ts) and import it from BOTH nestjs-codegen.config.ts (CLI) and NestjsCodegenModule.forRoot() (Nest module), or set \`driftGuard: false\` on either config to opt out of this check.`;
2325
2377
  }
2326
2378
  async function generate(config, inputRoutes = [], entryPoint = "cli") {
2327
2379
  setCodegenDebug(config.debug);
@@ -2332,9 +2384,17 @@ async function generate(config, inputRoutes = [], entryPoint = "cli") {
2332
2384
  return;
2333
2385
  }
2334
2386
  const configHash = computeConfigHash(config);
2387
+ const configKeyHashes = computeConfigKeyHashes(config);
2335
2388
  if (config.driftGuard && manifest?.entryPoint && manifest.entryPoint !== entryPoint && manifest.configHash && manifest.configHash !== configHash) {
2336
2389
  throw new DriftGuardError(
2337
- driftGuardMessage(config.codegen.outDir, manifest.entryPoint, entryPoint)
2390
+ driftGuardMessage(
2391
+ config.codegen.outDir,
2392
+ manifest.entryPoint,
2393
+ entryPoint,
2394
+ // A pre-key-hash manifest can't tell us WHICH keys differ — pass none
2395
+ // rather than diffing against {} (which would name every key).
2396
+ manifest.configKeyHashes ? diffConfigKeyHashes(manifest.configKeyHashes, configKeyHashes) : []
2397
+ )
2338
2398
  );
2339
2399
  }
2340
2400
  const extensions = config.extensions ?? [];
@@ -2404,6 +2464,7 @@ async function generate(config, inputRoutes = [], entryPoint = "cli") {
2404
2464
  hash: inputsHash,
2405
2465
  entryPoint,
2406
2466
  configHash,
2467
+ configKeyHashes,
2407
2468
  files: outputFiles
2408
2469
  });
2409
2470
  }
@@ -4919,7 +4980,7 @@ function createChainModuleRenderer(opts) {
4919
4980
  }
4920
4981
 
4921
4982
  // src/index.ts
4922
- var VERSION = "0.14.1";
4983
+ var VERSION = "0.15.0";
4923
4984
  // Annotate the CommonJS export names for ESM import in node:
4924
4985
  0 && (module.exports = {
4925
4986
  CodegenError,