NestJS migration layer — module & test guide
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
top-level dispatcher in src/index.ts routes it to the legacy app; migrated
prefixes go to Nest. Weather (weather/) is the reference implementation — copy
its shape when migrating a new domain.
Module layout (per domain)
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
Add the prefix to DEFAULT_NEST_PREFIXES in strangler.ts to route it to Nest
(operators can override at runtime via the NEST_PREFIXES env var — instant
rollback, no redeploy). Trip-scoped mounts use a pattern prefix with a :param
segment (e.g. /api/trips/:tripId/packing); the matcher routes only that nested
mount to Nest and leaves the sibling trip routes (days, places, ...) on Express.
Migrated so far
- Phase 1 (leaf): weather, airports, config (public), system-notices, maps, categories, tags, notifications, atlas.
- Phase 2 (trip sub-domains): vacay (addon), packing, todo.
- DI-native services (legacy
src/services/*deleted): tags, categories, todo, packing, day-notes, trip-invite, assignments, share, settings, files, collab, vacay, reservations, day — see the migration recipe below.
Cross-cutting Foundation pieces
common/idempotency.interceptor.ts— globalAPP_INTERCEPTORreplaying the client'sX-Idempotency-Keyon mutations, mirroring the legacyapplyIdempotencymiddleware so retried writes don't double-apply.strangler.ts— supports both static prefixes and:parampattern prefixes.app-config/— the@nestjs/configbinding (AppConfigModule, global). Never readprocess.envin a module (ESLint enforces this): inject a boot-stable namespace via itsregisterAstoken (@Inject(mcpConfig.KEY) … ConfigType<…>) or read runtime-toggled values live throughRuntimeEnvService/readEnv()fromsrc/app-config. The classification and invariants live insrc/app-config/README.md.
Parity gotchas worth remembering
- A POST that answers with
res.jsonin Express stays 200; add@HttpCode(200)(Nest defaults POST to 201). Creates that Express sends as 201 need nothing. - Static sub-routes that collide with a
:idparam (e.g./in-app/allvs/in-app/:id,/reordervs/:id) must be declared before the param route. - Reproduce bespoke admin/error wording exactly — e.g. notifications'
test-smtpreturns{ error: 'Admin only' }, not the AdminGuard'sAdmin access required. - Trip-scoped routes verify trip access (404) and the relevant permission (403)
per handler and forward
X-Socket-Idto the WebSocket broadcast.
Parity is law
A migrated route must be byte-identical for the client: same URL, method,
query/body, HTTP status, Set-Cookie, and JSON body — including bespoke error
strings. Where the legacy route returns a hand-written error (e.g. weather's
{ error: 'Latitude and longitude are required' }), reproduce that exact body in
the controller rather than relying on the generic ZodValidationPipe envelope.
How to write the tests
Every module ships three kinds of tests; the coverage gate (vitest.config.ts,
scoped to src/nest/**) requires ≥80%.
-
Service / controller unit spec —
tests/unit/nest/<domain>.controller.test.ts. Instantiate the controller with a mocked service; assert status codes, the exact{ error }bodies, and that inputs are forwarded correctly (defaults, coercion). Seeweather.controller.test.ts. -
Parity test —
tests/parity/<domain>.parity.test.ts. Mock the shared service identically for both apps, then fire the same request at the Express route and the Nest controller with theexpectParity()harness (tests/parity/parity.ts) and assert identical status + body. This is the gate before flipping the toggle. Seeweather.parity.test.ts. -
e2e —
tests/e2e/<domain>.e2e.test.ts. Boot the Nest module against a temp in-memory SQLite db via the shared harness (tests/e2e/harness.ts:createTempDb/seedUser/sessionCookie), exercising the realJwtAuthGuardend-to-end (401 without cookie, 200 with a signed session). Mock external I/O (HTTP/etc.). Seeweather.e2e.test.ts.
Definition of Done (per module)
Contract in @trek/shared → service ported 1:1 → controller with identical routes →
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
Nest → parity verified on the demo DB → then decommission the old Express
route/service (separate step, after the toggle is confirmed in prod) → frontend points
at the typed contract (Frontend Track).
Migrating a legacy src/services/* service into its Nest module (recipe)
Pilot: tags (services/tagService.ts → nest/tags/tags.service.ts +
nest/tags/tags.bridge.ts); categories followed the same shape (and piloted the
first @Resource in categories.mcp.ts); todo followed too (and piloted the
first @ResourceTemplate plus the when addon gate in todo.mcp.ts, and the
first in-container consumer wiring: TripsService injects TodoService via
exports: [TodoService] instead of using the bridge); packing followed (the
largest port so far: a 17-tool + 2-resource packing.mcp.ts with inline admin
gates, and the first PluginHostDepsFactory swap done as part of a service
migration — no bridge entry for the plugin host); day-notes followed (the first
migration needing no bridge at all: after its three tools + resource moved
to day-notes.mcp.ts and the plugin host injection, nothing outside the
container consumed it); trip-invite followed (the smallest port: no MCP
surface, no plugin-host import and no bridge — the SQL folded straight into
trip-invite.service.ts); assignments followed (a 7-tool assignments.mcp.ts,
the plugin-host swap, and a bridge kept only for the two legacy registrars —
places and reservations — that borrow its existence checks; the batch loaders
stay in services/queryHelpers.ts, shared with the unmigrated place
service); share followed (never imported by the plugin host, and its three MCP
tools stay in the legacy trips registrar — their trips:share scope gate has
no declarative access: { group, mode } equivalent — so the port is the SQL
fold plus a 3-export share.bridge.ts for mcp/tools/trips.ts); settings
followed (no MCP surface, no bridge, and the first migration that converted an
in-container plain-function consumer into a provider instead of bridging it:
llm-parse/llm-config.resolver.ts became the injectable LlmConfigResolver,
injected by LlmParseService and PluginHostDepsFactory); files followed (no
MCP surface and no addon gate; the load-time constants + pure helpers consumed
by three controllers' module-scope multer configs moved to
files.constants.ts, and files.bridge.ts survives with a single export —
the request-time getAllowedExtensions app_settings read those configs need
outside DI — while every function consumer, including the plugin host and
TripsService, injects FilesService); collab followed (a 12-tool +
3-resource collab.mcp.ts that piloted composite when gates — the collab
addon AND the per-sub-feature getCollabFeatures() flags (notes/polls/chat)
that the legacy registrar and resources checked at registration time — plus
the plugin-host swap and a 3-export collab.bridge.ts for the two remaining
legacy consumers, tripService's trip summary and mcp/tools/trips.ts);
vacay followed (the largest MCP port yet — a 26-tool + 3-resource
vacay.mcp.ts, including the first fixed-URI @Resource behind a when
addon gate — plus the plugin-host swap and a 1-export vacay.bridge.ts for
tripService's trip-window entry shift; the DTO ratchet for its 13
grandfathered body contracts landed as a sibling commit); reservations
followed (the residue fold: the wrapper ReservationsService was already
DI-native at the edge, so the 626-line legacy module folded into it — a 5-tool
- 1-resource
reservations.mcp.ts, the plugin host's last plain-function reservation import (notifyBookingChange) swapped for the injected service,TripsServiceandBookingImportServiceconverted from function imports to injection, and a 9-export + 3-typereservations.bridge.tsfor the legacytripService, the airtrail import/sync pair and the still-legacy transit + transports registrars; the DTO ratchet for its 4 grandfathered body contracts landed as a sibling commit, which also loosened the shared positions schema to the real wire contract —day_plan_positionoptional, pinned by RESV-006); day followed (the 592-line legacydayServicefolded into the existing wrapperDaysService— including the accommodation SQL thatnest/reservations/accommodations.service.tsnow reaches via an injectedDaysService— with the hand-rolledBEGIN/COMMITblocks in reorder/insert converted todb.transaction(); a 7-tool + 2-resourcedays.mcp.ts, the plugin host's 11-symbol import swapped for the injected service,TripsService+ the assignments/reservations MCP controllers converted to injection, and a 6-exportdays.bridge.tsfor the legacytripServiceand the still-legacy transit + transports registrars; the DTO ratchet for its 4 day + 2 accommodation grandfathered body contracts landed as a sibling commit). Repeat these steps per service (next up: permissions + auditLog — the Wave-2 cross-cutting pair the dependency-honest order inmigration-graph.mdpromotes now that dayService is folded; then the exchangeRateService fold → budgetService). This is a pure relocation — byte-identical SQL, statuses, bodies, and error strings. The plugin RPC host is no longer a bridge consumer: since Option A ofsrc/nest/plugins/DI-MIGRATION.mdit injects domain services viaPluginHostDepsFactory, so a migrated domain addsexports: [XService]+ aPluginsModuleimport instead of a bridge entry. Only legacysrc/mcpregistrars (and scheduler/websocket code) still need bridges.
- Move the SQL into
<domain>.service.tsas methods over an injectedDatabaseService(this.db.all<T>/get<T>/run/prepare/transaction; strict constructor injection, no@Optional()). Preserve every quirk: falsy-coercion defaults (x || fallback, never??), post-insert/post-update re-selects (no RETURNING),COALESCEsemantics. If a controller already wraps the legacy functions, do not change the service's method surface. The module needs noimports: [DatabaseModule]— it's@Global. - Add
<domain>.bridge.tsnext to the service only if non-Nest consumers exist (legacy MCP tool registrars, scheduler, websocket — the plugin RPC host now injects instead, see above). It builds a module-level instance over the shared connection Proxy —new XService(new DatabaseService(db)), reinitialize-proof, same pattern asnest/todo/todo.bridge.ts— and exports the legacy function names 1:1. Container code injects the service; only outside-container code imports the bridge. (Design decision, settled with the tags pilot: MCP tools stay outside the container and use the bridge. The alternative — handing the Nest app to the MCP layer viaapp.get(XService)— was rejected: it would thread the container throughmcpHandler+ every tool registrar and force a Nest bootstrap into the container-lesstests/helpers/mcp-harness.tsused by ~25 MCP suites.) - Repoint non-Nest consumers — import-path-only diffs from
services/<x>Servicetonest/<domain>/<domain>.bridge; call sites unchanged. - Delete the legacy service once
grep -rn "services/<x>Service" src testsonly hits the tests you're rewriting. - Tests:
- Move
tests/unit/services/<x>Service.test.ts→tests/unit/nest/<domain>.service.test.ts, preserving case IDs. Construct the service directly —new XService(new DatabaseService(testDb))— no TestingModule, nooverrideProvider(repo convention). Add one delegation case per bridge export: the bridge sits undersrc/nest/**, inside the ≥80% coverage gate, and these cases pin it deterministically. - Convert the module e2e to the DI-native pattern (exemplar
tests/e2e/trips.e2e.test.ts): temp-db DDL for the domain's tables,imports: [DatabaseModule, <Domain>Module], real SQL assertions. Keep only thevi.mock('../../src/db/database', …)— the auth guard still reads users through the singleton, andDatabaseModule's factory picks up the same mocked db. Drop the legacy-service mock entirely. - Suites that mocked the legacy module mock the bridge path instead — same
factory shape, path-only change. (For the plugin host suite,
plugin-host-deps.factory.test.ts, the domain becomes a constructor stub instead of a path mock.)
- Move
- Verify (from
server/):npm run typecheck,test:unit,test:integration,test:e2e,lint:check,test:coverage.