Fold the 1612-line services/atlasService.ts into the DI-native AtlasService: the stats aggregation, visited countries/regions with the #1490 tombstone and cascade logic, and the bucket-list CRUD become methods over the injected DatabaseService. The ~750-line pure-geo half — the bundled admin0/admin1 boundary stores with their #1576 streaming builders, the point-in-polygon indexes, Nominatim geocoding with its shared throttle, and every module-scoped cache including the import-time unref'd cleanup interval — moves verbatim to the plain module atlas-geo.ts so the caches stay process-global across the container instance, the bridge instance and test helpers (assetPath re-anchored one directory deeper, the only non-verbatim line). The 10-tool mcp/tools/atlas.ts registrar plus all four atlas resources in mcp/resources.ts move onto the decorator registry as atlas.mcp.ts — the when: atlas-addon gate is parity here (legacy tools and resources both gated; the REST controller deliberately does not). resources.test.ts retires with its last two cases, which move to tools-atlas-expanded.test.ts. The plugin RPC host swaps its 9 atlas imports for the injected AtlasService (23rd constructor dep); a minimal 2-export atlas.bridge.ts serves the one legacy consumer, authService.getTravelStats, and dies when authService migrates. Adopts atlas.dto.ts over the existing shared schemas and clears all three AtlasController entries from body-contract-allow-list.ts: the hand-rolled 'name and country_code are required' 400 becomes the ZodValidationPipe envelope (todo/places trade) while the whitespace-only bucket name keeps its legacy 'Name is required' trim guard. Fixes the verified defects the relocation had faithfully carried, each with a regression test (ATLAS-SVC-031..036 plus two MCP casing cases): the four multi-statement mark/unmark writes now run in db.transaction() (the region cascade nests as a savepoint), the trip-less countryPlaces early return honours manually_marked, the '|| null' bindings that dropped lat/lng 0 and empty-string notes on bucket update become '?? null', the mutating bucket SQL is user-scoped, and the MCP region/country-places tools uppercase their codes to match REST. Quirks deliberately preserved: the detached GET-path place_regions backfill writes, getStats' two divergent return shapes, the import-time cache-cleanup interval, getCountryFromAddress's unvalidated bare 2-letter codes, and the whitespace-only-name silent no-op on bucket update. Tests move with their case IDs: atlasService.test.ts becomes unit/nest/atlas.service.test.ts (pure-geo imports from atlas-geo, DB calls through the constructed service, plus bridge-delegation cases), the atlas e2e converts to the DI-native pattern (real SQL against the full temp-db schema), and the plugin-host path mock becomes a constructor stub.
26 KiB
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, permissions, audit, budget, trip, maps, transit, place, transit-itinerary, collections — 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 transports registrar (the transit registrar has since migrated totransit.mcp.ts); 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 transports registrar (transit has since migrated); the DTO ratchet for its 4 day + 2 accommodation grandfathered body contracts landed as a sibling commit); permissions followed (the first Wave-2 cross-cutting migration and the first greenfield module in the series — no prior wrapper, controller, MCP surface or DTO of its own: a newnest/permissions/whosePermissionsServiceis injected by 16 domain services, the airtrail-import controller andPluginHostDepsFactory(its 16th constructor dep) in one move, plus a 5-functionpermissions.bridge.tsformcp/tools/_shared.ts— one repoint covering everyhasTripPermissioncall site — and the four legacy consumers adminService/authService/backupService/collectionsService; the permissions cache stays module-scoped inpermissions.service.tson purpose, so the bridge instance and the container singleton share one cache and backup-restore's bridge-sideinvalidatePermissionsCache()flushes what request handlers serve; the domain e2e suites swapped their path mocks for avi.spyOn(app.get(PermissionsService), 'checkPermission')instance spy); auditLog followed (the other Wave-2 half, split into five files: the injectableAuditService(writeAuditoverDatabaseService), the pureclient-ip.ts(files.constants precedent — the four getClientIp-only controllers stay plain imports), the deliberately side-effectful plainaudit-log.logger.ts— frozen-at-importLOG_LEVELand the import-timedata/logsmkdir are a documented parity exception to the no-side-effects rule becauseindex.tslazy-requires it pre-container and tests/setup.ts sets the level pre-import — plusaudit.module.tsand a full-surfaceaudit.bridge.tsformcp/index.ts,mcp/oauthProvider.tsand the legacy airtrail/immich/oauth services, while log*-only consumers (index.ts's lazy require strings, scheduler, globalMiddleware, notifications) point at the logger directly; 8 controllers +PluginRuntimeServiceinjectAuditService, and the domain e2e suites dropped the audit mock entirely — writeAudit runs for real against anaudit_logtable in their temp DBs). The exchangeRateService fold followed (the pure-infra FX helper — Frankfurter fetch + module-scoped 6h cache, no SQL, no controller, no MCP registrar of its own — folded intonest/budget/as a dep-freeExchangeRatesService, injected byBudgetServiceandPluginHostDepsFactory(its 17th constructor dep); the rate cache stays module-scoped like the permissions cache, so any out-of-container instance and the container singleton share one cached upstream feed). budgetService followed (the 755-line Wave-4 money core folded into the wrapperBudgetService: items/members/payers CRUD, the FX freeze + rebase paths and the settlement maths withsplitEqualSharesgone private; the freeze-then-write composites kept their wrapper names while the raw settlement writes becameinsertSettlement/applySettlementUpdateso the MCP paths keep skipping the freeze; the 11-toolmcp/tools/budget.tsregistrar + 3resources.tsbudget resources moved tobudget.mcp.ts; TripsService, ReservationsService (+ its MCP class) and BookingImportService injectBudgetService; a 4-exportbudget.bridge.tsserves the legacy tripService/userCleanupService and the trips/transports registrars;exchange-rates.bridge.tswas deleted with its last consumers, and the controller adoptedbudget.dto.ts— all nine allow-list entries removed). tripService followed (the 1121-line Wave-4 hub — the biggest fold — moved into the wrapperTripsService: TRIP_SELECT + list/create/get, thegenerateDaystwo-phase renumber engine, the updateTrip date-shift transaction, the member/guest lifecycle (#973/#1362), the ICS export with its module-scoped tz-validity cache,copyTripByIdandgetTripSummary; its six bridge imports became injected services (CollabService + VacayService joined the constructor); the 10-tool trips registrar + 3resources.tstrip resources + the trip-summary prompt moved totrips.mcp.ts— the first@Promptuse, with the fire-once static-token deprecation notice now riding theregistry.attachctx — and the 3 share-link tools it carried moved toshare.mcp.tson thecanShareTripspredicate (delete_trip and the canReadTrips reads are predicates too — the broadened legacy gates have no declarative equivalent); FeedsService and the plugin host injectTripsService(its 20th constructor dep); a 3-exporttrips.bridge.tsserves the legacy prompts registrar and budget.mcp.ts's owner/member seam (injecting there would need a forwardRef'd TripsModule↔BudgetModule cycle);todo.bridge.ts,share.bridge.ts,collab.bridge.tsandvacay.bridge.tswere deleted with their last consumers and the unused days/budget bridge exports pruned; the controller adoptedtrips.dto.ts— all seven allow-list entries removed). mapsService followed (the 1429-line geo core — Google Places, Nominatim, Overpass mirror racing, Wikimedia photos, Maps-URL resolution — folded into the wrapperMapsService; the pure parser/UA/POI-category helpers moved tomaps.helpers.tsas plain exports (files.constants/client-ip precedent — the DI-native TransitService's User-Agent imports from there, not a bridge), the module-scoped POI cache / photo-fetch semaphore / frozen Overpass mirrors stayed module-scoped on purpose (permissions-cache precedent: any out-of-container instance and the DI singleton share them); the 3 geo tools left the mixedmcp/tools/mapsWeather.tsregistrar for the decorator-drivenmaps.mcp.ts(the registrar file survives — its weather + airport tools await their own migrations); BookingImportService injectsMapsServicefor its Nominatim geocoding, and a 3-exportmaps.bridge.tsserved the legacy placeEnrichment helper and the places registrar — both absorbed by the 2026-07 place fold, which deleted the bridge). transitService followed (the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-freeTransitService(noDatabaseService; the ExchangeRatesService precedent), its response cache, frozen-at-importTRANSIT_API_BASEand lazy User-Agent memo staying module-scoped on purpose; the purederiveTransitStats+ mode whitelist + itinerary types moved totransit.helpers.ts(maps.helpers precedent) so the downstream legacytransitItineraryServiceneeded no bridge (since relocated into the domain astransit-itinerary.helpers.ts); the whole 3-toolmcp/tools/transit.tsregistrar moved totransit.mcp.ts— the two geo search tools onaccess: { group: 'geo', mode: 'read' }andcreate_transit_journeyonreservations:write, with its days/reservations bridge imports becoming injectedDaysService/ReservationsService(+DatabaseServiceforcanAccessTrip) — leaving zero bridge files and the transports registrar asdays.bridge.ts's last consumer). placeService followed (the step-4 tail: the 1029-line place core — the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list importers — folded into the wrapperPlacesService, with the pure pieces (frozen XML parsers, the KMZ unpacker, the dedup predicates, the Google hex-id parsers,reclaimPhotoCache) moving toplaces.helpers.tson the maps.helpers precedent; the 10-toolmcp/tools/places.tsregistrar + thetrek://trips/{tripId}/placesresource moved toplaces.mcp.ts—search_placecame along because its gate isplaces:read, not the geo group, and now injectsMapsService; TripsService, DaysMcp, BookingImportService and the plugin host (its 21st constructor dep) injectPlacesService, leaving zero bridge files for the domain; the twoassignments.bridgeimports stay inplaces.mcp.tson purpose — AssignmentsModule imports DaysModule and DaysModule now imports PlacesModule, so injecting would close a DaysModule → PlacesModule → AssignmentsModule → DaysModule cycle (reservations.mcp.ts uses the same seam for the same reason). The siblingplaceEnrichmentfold went further than the recipe's minimum: the 168-line helper's DB/websocket/Maps half becamePlacesServicemethods over the injectedDatabaseService/RealtimeService/MapsServiceand its pure match selector joinedplaces.helpers.ts, which retiredmaps.bridge.tswith its last consumer. The DTO ratchet for its 7 grandfathered body contracts landed as a third commit, which also loosenedplaceBulkUpdateRequestSchema.ids(.min(1)dropped) so the endpoint's empty-list short-circuit stays reachable, and retired three bespoke 400 strings — 'Place name is required', 'ids must be an array of numbers' and 'URL is required' — in favour of the pipe envelope, accepting that a malformed body now 400s ahead of the trip-access 404 (the todo/trips trade). transitItineraryService followed (the first pure-helpers relocation with no service fold at all — the 287-line module is 100% pure, so the recipe's SQL / bridge / DTO / plugin-host steps were all no-ops: the Zod itinerary schemas + endpoint/metadata builders moved byte-identical totransit-itinerary.helpers.ts, next totransit.helpers.ts; the schemas must stay module-level plain exports becausetransit.mcp.tsconsumes them inside@Tool({ inputSchema })decorators, which evaluate at module load before any container exists; the sole consumer — the in-containertransit.mcp.ts— was a one-import repoint, closing step 4 of the dependency-honest order; the legacy module had no direct suite, so a new 21-caseTRANSIT-ITIN-*characterization suite now pins all 12 superRefine error strings, the??time fallbacks, the coordinate tolerances and the reservation endpoint/metadata builder). collectionsService followed (the biggest single fold yet — 1024 lines / 28 exports intoCollectionsServiceover DatabaseService + PermissionsService + RealtimeService, with thedeleteOldCollectionCoverpath re-anchored one directory deeper and thesendInvitelazy notificationServiceimport()kept, collab precedent; the 25-tool legacy registrarmcp/tools/collections.tsmoved tocollections.mcp.ts— at relocation time deliberately with NOwhen:addon gate, since the legacy registrar registered unconditionally while REST/plugin-host gate on the addon; the trailingfix(server)quirk commit then gated all 25 tools (the addon ships disabled by default, so the ungated surface was live on fresh installs) alongside wrapping every multi-statement write indb.transaction(), making the bulk ops all-or-nothing, and forwardingX-Socket-Idon the from-trip saves — each pinned by a new 23-casetools-collectionscharacterization suite plus theCOLLECTIONS-SVC-090…092band (the legacy registrar had no tool-level tests at all); the plugin host swapped its 7 collections imports for the injected service — its 22nd constructor dep — and NO bridge was needed anywhere; the deadbuildDedupSetmodule helper was dropped in the move, the only line that didn't relocate verbatim). atlasService followed (1612 lines split two ways, places precedent: the DB half — stats aggregation, visited countries/regions with the #1490 tombstone/cascade logic, bucket-list CRUD — folded intoAtlasServiceover DatabaseService alone, while the ~750-line pure-geo half — the bundled admin0/admin1 stores and their #1576 OOM-shaped streaming builders, the point-in-polygon indexes, Nominatim geocoding with its shared ≥1.1s throttle, the 50k geocode cache with its import-time unref'd cleanup interval, and thegeocodingInFlightdedup set — moved to the plain moduleatlas-geo.tsso those caches stay process-global across the container instance, the bridge instance and test helpers;assetPathre-anchored one directory deeper, the only non-verbatim line. The 10-tool legacy registrarmcp/tools/atlas.tsplus all four atlas resources inmcp/resources.ts(trek://bucket-list,trek://visited-countries,trek://atlas/stats,trek://atlas/regions) moved toatlas.mcp.ts— here thewhen:atlas-addon gate IS parity, since the legacy registrar and resources both gated on the addon while the REST controller deliberately does not; the mark_region_visited / get_country_atlas_places no-uppercase divergence from REST relocated untouched. The plugin host swapped its 9 atlas imports for the injected service — its 23rd constructor dep — and a minimal 2-exportatlas.bridge.tsexists solely for the legacyauthService.getTravelStatsedge (getCountryFromCoordsre-exported straight from atlas-geo,getHiddenCountriesover the bridge instance); it dies when authService migrates. resources.test.ts retired with its last two cases — every resource it covered now lives in the domain suites. The trailingfix(server)quirk commit then wrapped the four multi-statement mark/unmark writes indb.transaction()(the region cascade nests as a savepoint), made the trip-lesscountryPlacesearly return honourmanually_marked, fixed the|| nullbindings that droppedlat: 0/lng: 0/notes: ''on bucket update, user-scoped the mutating bucket SQL, and uppercased the MCP region/country-places codes to match REST — each pinned byATLAS-SVC-031…036plus two MCP casing cases; see migration-graph.md's "Quirks fixed after the atlas fold".) Repeat these steps per service (next up: the notifications fan-in — per the dependency-honest order inmigration-graph.md). 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. When porting an MCP registrar, note theaccess: { group, mode }markers are typed against the scope-derivedScopeGroupunion and boot-validated bytrekMcpValidateAccess(src/mcp/nest-mcp-policy.ts) — an unknown group, ormode: 'write'on a read-only group (geo,weather), fails app boot. (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.