@cogs/kubb-transforms
v0.4.0
Published
jscodeshift-based code transforms for maintaining Kubb v5-generated codegen output
Readme
@cogs/kubb-transforms
Post-generation codemods for Kubb v5 output.
Kubb generates a correct but deliberately minimal data layer. This package
closes the gap between "compiles" and "production-ready": it points Kubb's
bundled fetch runtime at @cogs/fetch-client, seeds placeholderData, adds
Next.js cache tags via @cogs/react-query's nextHashKeys, opens the four
mutation lifecycle seams Kubb emits none of, and names the repeated error type.
It runs as a single output.postGenerate command, so a failing transform fails
kubb generate itself and CI can never publish half-transformed codegen.
Targets Kubb 5 only (kubb, @kubb/plugin-fetch, @kubb/plugin-react-query,
@kubb/plugin-ts, @kubb/plugin-zod); there is no v4 mode.
Install
pnpm add -D @cogs/kubb-transformsWiring
Add one command to your kubb.config.ts. pluginReactQuery must import from the
react-query seam so the hook transforms recognise the output:
import { defineConfig } from 'kubb/config'
export default defineConfig({
output: {
path: './src',
postGenerate: ['npx cogs-kubb-transforms run'],
},
plugins: [
// ...pluginTs, pluginZod, pluginFetch
pluginReactQuery({
client: 'fetch',
hooks: true,
query: { methods: ['GET'], importPath: '@cogs/react-query' },
mutation: {
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
importPath: '@cogs/react-query',
},
}),
],
})The generated runtime (src/.kubb/client.ts) then imports
@cogs/fetch-client/kubb-transport, so call configureClient({ baseUrl, ... })
from @cogs/fetch-client during app bootstrap.
The seam sets serializer.path through client.setConfig, which Kubb merges
shallowly: a later client.setConfig({ serializer }) in app code replaces it and
drops the dot-segment guard. Set the whole serializer object, or leave it alone.
Changing the runtime.* options takes effect on the next kubb generate, which
re-copies the runtime; re-running the CLI alone does not rewrite an existing seam.
Optionally drop a kubb-transforms.config.ts next to kubb.config.ts:
import { defineConfig } from '@cogs/kubb-transforms'
export default defineConfig({
// Matches `output.path` in kubb.config.ts.
outDir: './src',
// Cross-package dedup map (see "Import map" below).
map: './kubb-map.json',
zod: {
unwrapLazySafe: true,
},
}).json, .js, .mjs and .mts are also accepted. A TypeScript config is
loaded through Node's native type stripping, which needs Node >= 22.18; on older
runtimes use kubb-transforms.config.json.
CLI
npx cogs-kubb-transforms run [options]
--config=<path> Config file path. Default: probe kubb-transforms.config.*
in the current directory.
--cwd=<path> Directory to resolve config and output from.
--dry-run Report what would change without writing.
--quiet Suppress per-stage progress output.
-h, --help Show help.Every transform is also importable as a pure (source, options) => string | null
function, so you can drive them yourself:
import { resolveConfig, transformHooks } from '@cogs/kubb-transforms'
const config = resolveConfig({ hooks: { nextTags: false } })
const output = transformHooks(source, { config })Stage auto-detection
A stage runs when its output exists: the hooks, types and zod stages when
their directory does, the runtime stage when Kubb's bundled .kubb/client.ts
does. You never declare "also run the hooks stage"; set hooks: false to opt
out.
Stages run in order: zod → types → runtime → hooks.
Config
| Field | Default | Purpose |
| --- | --- | --- |
| root | cwd | Project root other paths resolve against |
| outDir | ./src | Kubb's output.path |
| dirs | {hooks, types, zod} | Per-generator subdirectory names |
| map | — | Path to the cross-package import map JSON |
| fetchClient | @cogs/fetch-client | Transport seam module specifier |
| reactQuery | @cogs/react-query | Query seam module specifier |
| quote | single | Re-print quote style |
| semicolons | strip | Statement-terminator handling |
| stripBanner | true | Remove the Generated by Kubb header (not from the runtime) |
| runtime.file | .kubb/client.ts | Kubb's bundled runtime, relative to outDir |
| runtime.transportModule | <fetchClient>/kubb-transport | Module exporting kubbTransport, rejectDotSegment, normalizeKubbError |
| runtime.rejectDotSegments | true | Refuse a ./.. path param |
| runtime.normalizeErrors | true | xior-style error message, .data mirrored onto .error |
| hooks.mergeOptions | false | Skip injection when a caller spread is present |
| hooks.placeholderData | true | Seed useQuery from cache |
| hooks.nextTags | true | Merge options.next.tags = nextHashKeys(queryKey) into the client call |
| hooks.mutationLifecycle | true | onMutate/onSuccess/onError/onSettled seams |
| hooks.errorAlias | true | Factor the repeated error type into an alias |
| hooks.transportErrors | true | Widen that alias to ResponseErrorConfig<...> \| XiorError: network and timeout failures reject with the raw xior error, not Kubb's ResponseError |
| hooks.queryOptsParam | true | Third queryOpts param on *QueryOptions() |
| hooks.queryKeyNote | false | Emit the explanatory queryKey NOTE comment |
| hooks.queryOptionsSpreadName | resolvedOptions | Kubb's caller-options identifier |
| hooks.mutationOptionsSpreadName | mutationOptions | Kubb's caller-options identifier |
| zod.rewriteImports | true | Redirect duplicated schemas via the import map |
| zod.unwrapLazySafe | false | Unwrap z.lazy() when provably acyclic |
| types.rewriteImports | true | Redirect duplicated types via the import map |
Import map
When several generated API packages describe the same upstream model, each regenerates its own local copy. The map redirects the duplicates at one canonical package:
{
"types": { "ApiError": "@acme/shared-types" },
"zod": { "apiErrorSchema": "@acme/shared-schemas" },
"lazyUnwrap": { "deny": ["RecursiveSchema"] }
}Passes: kept / dropped / adapted for Kubb v5
Every verdict was decided by generating real output with Kubb 5.12.6
(@kubb/plugin-fetch 5.7.1, @kubb/plugin-react-query 5.5.5,
@kubb/plugin-ts 5.0.15, @kubb/plugin-zod 5.4.13) against a 4-endpoint
scratch spec and reading it. The scratch spec and plugin config live in e2e/;
src/__tests__/e2e.test.ts runs the real Kubb on every test run and fails if
the captured fixtures in src/__tests__/fixtures.ts stop matching its output.
| Pass | Verdict | Evidence |
| --- | --- | --- |
| Fetch runtime → @cogs/fetch-client | NEW (replaces the client stage) | v5 no longer imports a client from @cogs/fetch-client/client; pluginFetch copies a ~1000-line runtime to .kubb/client.ts and every client, hook and handler sends through its exported client. One block is appended that calls client.setConfig({ transport }) with kubbTransport, wraps the path serializer with rejectDotSegment, and installs normalizeKubbError. Text-level and idempotent; throws if the runtime's layout changes. |
| Client-stage passes (import merge, fetch → client, URL-helper collapse, requestData → data, confkey/operations.ts) | DROPPED | None of the shapes exist in v5: clients are withUnwrap(request({ method, url, ...config })), there are no *Url() helpers, no requestData, and pluginFetch has no operations option. The multi-API confKey registry routing has no v5 equivalent: kubbTransport sends through the single process-wide configureClient() instance, so one app talks to one configured API per process for now. |
| Banner-comment strip | KEEP | Every generated file still opens with the Generated by Kubb banner (now *-prefixed). Skipped for the runtime, which Kubb re-copies verbatim. |
| placeholderData + getPlaceholderData | KEEP | v5 hooks still destructure const { client: queryClient, ...resolvedOptions } = queryConfig and spread it into useQuery. |
| Next.js cache tags | ADAPT | v5 has no config.signal anchor and the request type carries options?: FetchOptions (RequestInit & { next? }). Tags are now merged into the client call's options right after ...config (options: { ...config.options, next: { ...config.options?.next, tags } }) instead of mutating the caller's config. kubbTransport forwards options to fetch. |
| queryOpts third parameter | KEEP | *QueryOptions() factories still build their options as an object literal passed to queryOptions<...>(). |
| Mutation lifecycle skeleton | KEEP, bare pass-through | useMutation({ ...baseOptions, mutationKey, ...mutationOptions }) still has no lifecycle handlers. See the decision note below. |
| addErrorAlias | ADAPT | v5 names no *QueryResponse types, so the alias is derived from the hook name (useGetWidget → GetWidgetErrorResponse, mutations get …MutationErrorResponse). An operation with no documented errors yields ResponseErrorConfig<Error>, which is aliased the same way. |
| Zod .and() → .merge() | DROPPED | v5 emits allOf as base.extend({...}). |
| Zod z.object({}).catchall(X) → z.record(X) | DROPPED | v5 emits additionalProperties as z.record(z.string(), X). zod.recordKeyType and zod.assumeImportedSchemas went with these two passes. |
| Zod safe .lazy() unwrap | KEEP | v5 still wraps cross-file $refs in z.lazy(() => xSchema), including non-cyclic ones. Recursion inside a file is a getter (get child() { return widgetSchema.optional() }), so it never reaches this pass. A cycle spanning files cannot be proven from one file: deny those names in the import map, or leave zod.unwrapLazySafe off. |
| Types-imports / zod-imports dedup | KEEP | Monorepo-structural and keyed by imported name, so independent of the Kubb version. |
Why the mutation skeleton stays a bare pass-through
The injected handlers are mutationOptions?.onSuccess?.(...) re-dispatches, not
a ready-made optimistic update wired to @cogs/react-query's cancelAndSnapshot
/ rollback / bumpUpdatedAt.
An optimistic update has to know which query keys the mutation invalidates,
and that is domain knowledge no OpenAPI document carries. A mutation's own
generated mutationKey ([{ url: '/widgets' }]) is not the list or detail
query key that needs cancelling and rolling back — deriving that requires
cross-file inference the codegen cannot do. Emitting
cancelAndSnapshot(queryClient, [/* ? */]) would produce code that either fails
to compile or silently does nothing, on every regeneration.
The four seams are the honest deliverable: they are idempotent, semantically
neutral, and they put the extension point exactly where the app author needs it.
Wiring the determinism helpers into them is a per-mutation decision, and
@cogs/react-query documents that pattern.
MCP output: out of scope
@kubb/plugin-mcp output is not transformed. It carries its own auth-token and
tool-registration concerns, and no plugin set this package is built for emits it.
Regenerating the fixtures
pnpm --filter @cogs/kubb-transforms exec kubb generate --config e2e/kubb.config.tswrites the raw output to e2e/.generated (gitignored). Copy the changed files
into src/__tests__/fixtures.ts; e2e.test.ts tells you exactly which ones
drifted. To move to a newer Kubb, bump the seven kubb / @kubb/* devDependencies
together (they pin each other), run the tests, and fix whatever drifted.
License
MIT
