@cogs/kubb-transforms
v0.2.0
Published
jscodeshift-based code transforms for migrating and maintaining Kubb-generated codegen output
Readme
@cogs/kubb-transforms
Post-generation codemods for Kubb output.
Kubb generates a correct but deliberately minimal data layer. This package
closes the gap between "compiles" and "production-ready" — merging the split
transport imports, collapsing the URL helpers, wiring confkey routing into
@cogs/fetch-client's operation registry, seeding placeholderData, adding
Next.js cache tags via @cogs/react-query's nextHashKeys, opening the four
mutation lifecycle seams Kubb emits none of, and converging zod intersections
into merges.
It runs as a single hooks.done command, so a failing transform fails
kubb generate itself and CI can never publish half-transformed codegen.
Install
pnpm add -D @cogs/kubb-transformsWiring
Add one line to your kubb.config.ts:
export default defineConfig({
// ...plugins
hooks: {
done: ['npx cogs-kubb-transforms run'],
},
})Then drop a kubb-transforms.config.ts next to it:
import { defineConfig } from '@cogs/kubb-transforms'
export default defineConfig({
// Matches `output.path` in kubb.config.ts.
outDir: './src',
// Routes generated requests to a named entry in @cogs/fetch-client's
// config registry: setConfig('envmgr', { baseUrl, getToken }).
clients: {
confKey: 'envmgr',
registerName: 'registerEnvmgrOperations',
},
// 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, transformClient } from '@cogs/kubb-transforms'
const config = resolveConfig({ clients: { confKey: 'envmgr' } })
const output = transformClient(source, { config, operationId: 'getWidget' })Stage auto-detection
A stage runs when its output directory exists and contains .ts files —
mirroring the shopt -s nullglob guards in the shell scripts this replaces. You
never declare "also run the hooks stage"; set hooks: false to opt out.
Stages run in dependency order: zod → types → clients → hooks.
Config
| Field | Default | Purpose |
| --- | --- | --- |
| root | cwd | Project root other paths resolve against |
| outDir | ./src | Kubb's output.path |
| dirs | {clients, 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 |
| clients.confKey | — | Injected as confkey into requests + operations.ts |
| clients.registerName | registerOperations | Name of the appended registration function |
| clients.operationsFile | operations.ts | Basename of the generated registry |
| clients.collapseUrlHelpers | true | {method,url} object → bare string return |
| clients.renameRequestData | true | Drop the requestData hop |
| clients.mergeFetchClientImports | true | Merge the two seam imports into one |
| hooks.mergeOptions | false | Skip injection when a caller spread is present |
| hooks.placeholderData | true | Seed useQuery from cache |
| hooks.nextTags | true | config.next = { tags: nextHashKeys(queryKey) } |
| hooks.mutationLifecycle | true | onMutate/onSuccess/onError/onSettled seams |
| hooks.errorAlias | true | Factor the repeated error type into an alias |
| 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.mergeIntersections | true | .and() → .merge() when provably safe |
| zod.catchallToRecord | true | z.object({}).catchall(X) → z.record(X) |
| zod.recordKeyType | — | Set to 'z.string()' on Zod 4 (two-arg z.record) |
| zod.unwrapLazySafe | false | Unwrap z.lazy() when provably acyclic |
| zod.assumeImportedSchemas | true | Trust imported *Schema names as objects |
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"] }
}Ported passes: keep / drop / adapt
All five original scripts came from envmgr-ui/scripts/transform-kubb-*.js,
written against an older Kubb and against @ad-infrastructure/fetch-client (a
xior wrapper). Every verdict below was decided by generating real output with
Kubb v4.39.3 against a 4-endpoint scratch spec (GET list, GET by id, POST
with body, PATCH with body) and reading it — not by assumption. The captured
"before" shapes live in src/__tests__/fixtures.ts.
| Pass | Verdict | Evidence |
| --- | --- | --- |
| Banner-comment strip | KEEP (+ fix) | Every generated file still opens with /** Generated by Kubb ... Do not edit manually. */. Fixed a latent bug: the strip used to be folded into other passes, so a file nothing else touched kept its banner. It is now an independent pass. |
| Fetch-client import merge/rewrite | ADAPT | v4 emits two imports from the same specifier — import fetch from '@cogs/fetch-client/client' and import type { Client, RequestConfig, ResponseErrorConfig } from '@cogs/fetch-client/client'. The original split them across the package root and the /client subpath. Both now merge into one statement with per-specifier type modifiers. |
| fetch → client rename | KEEP in clients, DROP in hooks | Client files really do bind fetch (const { client: request = fetch } = config), shadowing the global. Hook files import the client function by name (import { listWidgets } from '../../clients/listWidgets.ts') and never bind fetch — the pass was a no-op there, and the original additionally injected an unreferenced import type client from '<seam>/client'. |
| XiorError<T> wrap of ResponseErrorConfig<T> | DROP | Raw output emits ResponseErrorConfig<GetWidget404> bare. @cogs/fetch-client's FetchClientError<T> implements ResponseErrorConfig<T> directly, so the generated type is already correct. No analogous rename or wrap is needed — verified by reading the raw output, not inferred from the type definition. |
| URL-helper collapse | KEEP | v4 emits function getGetWidgetUrl(...) { const res = { method: 'GET', url: \/widgets/${id}` as const }; return res }— unexported — and calls it asgetGetWidgetUrl({...}).url.toString(). Collapsed to a bare string return, exported, and the call site shortened to .toString(). |
| requestData→data | **KEEP** | Present verbatim in POST and PATCH clients (const requestData = data; ... data: requestData); absent in GET. Shorthand is restored after the rename. |
| confkey/operationIdinjection + registry | **KEEP, rewired** |operations.tsis emitted untyped with onlypath/method. Now annotated Operationsand populated withconfkey+operationIdfrom@cogs/fetch-client's real API, replacing the ad-hoc Operationstype the original invented. Registration is an exported **named function** rather than a module-scopeaddOperations(operations)— importing a generated barrel should not silently mutate a process-wide registry, andaddOperationsthrows on conflicting keys, which would turn an innocent import into a crash. |
|addErrorAlias| **KEEP** (+ fix) | The identical error type repeats 3× in a query hook and 5× in a mutation hook. Two fixes: it now matches **balanced angle brackets** instead of a non-greedy/ResponseErrorConfig<(.*?)>/, which truncated nested generics like ResponseErrorConfig<Foo>; and it requires 2+ occurrences, since aliasing a single use is noise. |
| placeholderData+getPlaceholderData| **KEEP, adapted** | RawuseQueryhas noplaceholderData. The caller-options spread is ...resolvedOptions, **not** ...queryOptionsas the original assumed —const { client: queryClient, ...resolvedOptions } = queryConfig. Injection now happens *after* the spread rather than before it, and the identifier is configurable. |
| config.next = { tags: nextHashKeys(queryKey) }| **KEEP, adapted** | Raw query fns have noconfig.next. Wired to @cogs/react-query's real nextHashKeysexport. The original anchored the insertion after aconfig.signal = signalstatement; v4 has none (it passes{ ...config, signal: config.signal ?? signal }inline), so the assignment now goes to the top of the query fn — still before the spread readsconfig. The signal anchor is retained as a fallback for older output. |
| Mutation lifecycle skeleton | **KEEP, bare pass-through** | Raw useMutation({ ...baseOptions, mutationKey, ...mutationOptions })has no lifecycle handlers. See the decision note below. |
| Zod.and()→.merge()| **KEEP** (+ heuristic fix) | Confirmed:allOfbecomesz.lazy(() => a).and(z.lazy(() => b)).and(z.object({...})). One adaptation — assumeImportedSchemasonly trusted specifiers containing/zod/, but v4 emits sibling relative imports (./widgetBaseSchema.ts), so the convergence never fired. Relative specifiers now count. |
| Zod z.object({}).catchall(X)→z.record(X)| **KEEP** | Confirmed:additionalProperties: {type: string}becomesz.object({}).catchall(z.string()). Added zod.recordKeyTypebecause Zod 4 requires a two-argumentz.record. |
| Zod safe .lazy()unwrap | **KEEP** | Confirmed and load-bearing: the scratch spec's self-referentialWidget.child?: Widgetproduces a genuinely recursive schema. Tarjan SCC cycle detection keeps that one wrapped while unwrapping the safe operation-level lazies. |
| Types-imports / zod-imports dedup | **KEEP** | Monorepo-structural, independent of the transport. v4's new explicit.ts` extensions in specifiers do not affect it — the map is keyed by imported name, not module specifier. |
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 transforms: out of scope
transform-kubb-mcp.js and transform-kubb-mcp-tools.js (~980 lines) are
not ported. They target @kubb/plugin-mcp output, which none of the
kubb.config.ts plugin sets this package is built for emits, and they carry
their own auth-token and tool-registration concerns. Keeping them out holds the
initial surface to the five transforms every consumer actually runs; they can be
added later as a mcp stage without disturbing anything here.
License
MIT
