@nage-api/compat
v1.0.0-beta.4
Published
Legacy envelope shim and migration codemods for moving a nest-core-v2 app onto @nage-api
Readme
@nage-api/compat
Migration tooling for an application moving off nest-core-v2 (PLAN.md §23, §25 P1).
Two halves that share nothing but a release:
- a runtime shim that lets a ported controller keep answering old clients in the old shape, so the controller and its callers can move weeks apart;
- codemods, a build-time tool that an application never loads.
The package is temporary by design. When no route carries @LegacyEnvelope()
any more, delete the import and the dependency; nothing else holds it in place.
Which output to trust
Every codemod declares a kind, and the distinction is the whole point of this
surface. A codemod that is right most of the time is worse than no codemod: it
produces a large, plausible diff that gets approved by scrolling.
| Codemod | Kind | What it does |
| ------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| import-paths | rewrite | Repoints …/core.responses at @nage-api/compat and …/core.decorators at @nage-api/core. Reports the other eleven legacy modules. |
| env-rename | rewrite | Fixes the RECAPTCHA_SECRET_KET typo in source and .env. Reports JWT_SECRET and APP_ENGINE. |
| res-envelope | report | Locates every @Res() handler and manual envelope build, and says which of three ports each one is. |
| config-inventory | report | Inventories every environment variable and config namespace the app reads — the input to env.schema.ts. |
| job-generics | report | Locates the anys that defeated the legacy Job / ModelService generics. |
A rewrite edits the file, and only where the result is what a careful
developer would have typed: the two rewritten specifiers move name for name onto
successors this repository ships, and the rewrite is skipped entirely if any name
on the import has no successor. A report never edits anything; it prints
path:line, what is wrong there and what to do, because the decision needs a
fact the source does not carry.
runCodemods enforces that: a codemod declared as a report that returns a file
change throws rather than writing it.
Running them
npx nage-codemod ../nest-core-v2 # report only; nothing is written
npx nage-codemod ../nest-core-v2 --write # apply the rewrites
npx nage-codemod ../nest-core-v2 --only import-paths --write
npx nage-codemod --listReporting is the default and --write is opt-in, which is the inverse of most
codemod runners. Three of the five produce nothing but notes, so the useful first
run is a read-only one.
The same thing programmatically, for a runner of your own:
import { runCodemods } from '@nage-api/compat';
const report = runCodemods({
files: [
{
path: 'src/order.controller.ts',
content: "import { Result } from '../../core/core.responses';\n",
},
],
});
// `changed` lists the paths whose content differs; `files` carries every file,
// edited or not, so a caller can write the whole set back or diff it.
export const rewritten: readonly string[] = report.changed;
export const toRead: number = report.notes.length;Serving the old envelope, one route at a time
NageCompatModule.forRoot() changes nothing on its own — the interceptor it
registers only acts on routes marked @LegacyEnvelope():
import { Module } from '@nestjs/common';
import { NageCompatModule } from '@nage-api/compat';
@Module({ imports: [NageCompatModule.forRoot()] })
export class AppModule {}import { Controller, Get } from '@nestjs/common';
import { LegacyEnvelope } from '@nage-api/compat';
import type { Paginated } from '@nage-api/contracts';
interface Order {
readonly id: number;
readonly reference: string;
}
declare const orders: { findAll(): Promise<Paginated<Order>> };
@Controller('orders')
export class OrderController {
/** Ported: returns data, never touches `res`. Old clients still read `records`. */
@Get()
@LegacyEnvelope()
findAll(): Promise<Paginated<Order>> {
return orders.findAll();
}
}That route answers { records, offset, limit, count, timestamp } — the counters
back at the top level, undoing the meta.pagination nesting §16.1 introduced.
Remove the decorator when the clients have moved and the route rejoins the
standard envelope.
The flag is per route, not per application. NageCoreModule registers
ResponseInterceptor unconditionally, so a global switch would have to win a
race with it decided by module import order — a correctness property invisible at
the call site. @LegacyEnvelope() instead rides on @NoEnvelope(), which makes
the core interceptor stand down for exactly these routes whatever order the
modules were imported in. It also matches how §23.3 phase 5 says to migrate:
controller by controller.
The legacy body is reconstructed, so check it
nest-core-v2 is not a dependency of this repository and its
src/core/core.responses.ts is not readable from here. The default body is
rebuilt from the two call sites PLAN.md quotes — Result(res, { data, message })
(§4.1) and Result(res, { records, offset, limit, count }) (§22) — plus §16.1's
note that the old envelope stamped timestamp on 200s and not on errors. So it
spreads the payload and stamps a timestamp, which is the only behaviour both
quoted call sites agree on.
Diff it against your own and, where it differs, supply the builder rather than patching this one:
import { NageCompatModule, type LegacyBodyBuilder } from '@nage-api/compat';
import { Module } from '@nestjs/common';
const ourBody: LegacyBodyBuilder = ({ payload, timestamp }) => ({
status: 'success',
result: payload,
timestamp,
});
@Module({ imports: [NageCompatModule.forRoot({ body: ourBody })] })
export class AppModule {}One thing is deliberately not reproduced: the legacy ErrorResponse echoed
error.message || error to the client, which is how SQL text and upstream
response bodies reached callers (§5.2). legacyErrorBody takes an already
sanitised ErrorPayload, so the shape comes back and the leak does not.
Result and Created for controllers that still hold @Res()
The import-paths codemod repoints …/core.responses here, which makes a
legacy controller compile and behave inside a @nage-api application before anyone
touches its signature:
import { Controller, Get, Res } from '@nestjs/common';
import { Result, type LegacyResponse } from '@nage-api/compat';
@Controller('orders')
export class OrderController {
@Get()
findAll(@Res() res: LegacyResponse): unknown {
return Result(res, { records: [], offset: 0, limit: 10, count: 0 });
}
}This is a step, not a destination. A handler with @Res() in its signature is in
library-specific mode: Nest ignores what it returns, ResponseInterceptor never
sees the value and AllExceptionsFilter never sees its errors. That is the defect
§16.1 replaced, and res-envelope lists every site of it.
The response is typed as LegacyResponse — two methods — rather than Express's
Response, so this package pulls no HTTP framework into an application running
Fastify, and a test can pass an object literal.
What this deliberately does not do
- It does not repeat
nage doctor --legacy. That scan finds the insecure literals the legacy framework shipped —rejectUnauthorized: false,enableCors(),Math.random(,synchronize: true, the seeded credentials. Run it first; nothing here duplicates it. - It does not rewrite
Result(res, x)toreturn x. Rewriting the body without removing the@Res()parameter produces a route that hangs, and removing a parameter changes the signature, its callers and its tests — none of which is decidable from the line being rewritten.res-envelopereports each site with which of the three shapes it is. - It does not repoint
…/core.errors.NotFoundErrorandValidationErrorexist in@nage-api/coreunder the same names, so that rewrite would compile — and change the status code and the stableerror.codethe client receives. A rewrite whose failure mode is a passing build is the one not to make. - It ships no entity/schema introspection codemod, though §23.4 names one.
Its input is a live database's
information_schemarather than source; its output is a migration in a formatnage db migratecannot yet apply; and nothing here could test it against a real schema. Written against a guess it would be the most dangerous artefact in the set — a plausible schema migration for a production database.
Not yet implemented
- Errors on a
@LegacyEnvelope()route still get the new error envelope.AllExceptionsFilteris global and this package does not replace it. Restoring the old error body would mean a second filter carrying its own copy of the status mapping, and the old shape is the one that leaked. @NoEnvelope()and@LegacyEnvelope()cannot both be meaningful on one route; the second is built from the first.- None of this has been validated against a real
nest-core-v2application. §28.10 asks for the migration guide to be proved by porting one end to end, and no such application is reachable from this repository. The codemods are tested against before/after fixtures written from PLAN.md's evidence, which proves they do what they say — not that what they say covers a real codebase. Treat the first run on a real repository as the review it is.
