Files
TREK/migrate.md
T
jubnl 414d967223 docs: regenerate migration-graph after the admin fold
Records the fold and corrects two long-carried errors: there is no admin MCP
surface (the '11 MCP consumers' figure predated the Phase-0 addons extraction)
and systemNotices/conditions.ts was never a consumer. The memories/admin edge is
documented in the right direction for the first time. journeyService and
oauthService now head the frontier.
2026-08-02 15:08:02 +02:00

17 KiB

Recommended order

Wave 1 — pilots (establish the template on easy wins)

  1. tagService (26 lines, zero deps, only consumers: TagsModule + 1 MCP tool) — the ideal first migration. Small enough that the whole pattern fits in one PR: move the SQL into TagsService with injected DatabaseService, retire the legacy file, convert its tests to overrideProvider(DATABASE_CONNECTION).
  2. categoryService (31 lines, same shape) — immediate repeat to confirm the template.
  3. todoService (120 lines, classic CRUD with transactions + broadcast) — first "full-shaped" domain: prepared statements, db.transaction, WebSocket broadcast, 3 MCP tools. After this one, the recipe is settled for everything else.

The pilots deliberately include MCP consumers, because they force the one open design decision early: MCP tools run outside the Nest container, so either the migrated module keeps thin function exports delegating to a shared instance, or the MCP layer gets a handle to the Nest app (app.get(TodoService)). Better to settle that on a 26-line service than on authService.

Wave 2 — cross-cutting leverage (parallel to wave 3, high payoff)

  1. permissions (149 lines, zero deps, 17 Nest consumers) → an injectable PermissionsService (done 2026-07 — every module wrapper, the airtrail-import controller and PluginHostDepsFactory now inject it; the cache stays module-scoped so the DI singleton and permissions.bridge — kept for mcp/_shared.ts + adminService/backupService — share one invalidation; the authService and collectionsService consumers fell away with their own folds). Every module wrapper already calls checkPermission; this converts the single most-imported seam in one move.
  2. auditLog (171 lines, 13 Nest consumers) → AuditService (done 2026-07 — writeAudit is the injectable; getClientIp and the log*/LOG_LEVEL logger stay plain modules in nest/audit/ (client-ip.ts, audit-log.logger.ts with its frozen-at-import level + mkdir as a documented parity exception); audit.bridge covers mcp/index, oauthProvider and the legacy airtrail/immich/oauth services).
  3. tripAccess (9 lines) — don't migrate, delete: it's a wrapper around canAccessTrip, which DatabaseService now exposes. Absorb its 11 call sites opportunistically.

Wave 3 — domain services with existing modules, low fan-in

  1. dayNoteService (done 2026-07), tripInviteService (done 2026-07), assignmentService (done 2026-07), shareService (done 2026-07), settingsService (done 2026-07), fileService (done 2026-07), collabService (done 2026-07), packingService (done 2026-07) — each maps 1:1 to a Nest module that's already a thin delegate; none is imported by more than one other legacy service.

Wave 4 — the coupled cluster (order matters here)

  1. dayService → budgetService → reservationService in that order: reservations depend on both (budget-item linking, day resolution), so migrating the dependencies first lets ReservationsService inject the finished versions instead of importing legacy functions. — Correction (migration-graph.md, borne out by the migration): the claimed ordering constraint doesn't exist at the service layer. reservationService (done 2026-07) imported neither budgetService nor dayService; the budget/day coupling lives in the Nest wrapper's budget-sync seam and the MCP registrars, which keep their legacy imports until those domains migrate. Reservations went first as the frontier residue fold; dayService (done 2026-07 — the 592-line service folded into the wrapper DaysService, the accommodations seam in nest/reservations/ now injects it, and the hand-rolled reorder/insert transactions became db.transaction()) followed; budgetService followed (done 2026-07 — the 755-line money core folded into the wrapper BudgetService (settlement maths, FX freeze/rebase, items/members/payers CRUD; the raw settlement writes became insertSettlement/applySettlementUpdate so the MCP paths keep skipping the freeze); the 11-tool mcp registrar + 3 budget resources moved to the decorator-driven budget.mcp.ts; TripsService/ReservationsService/BookingImportService now inject BudgetService; a 4-export budget.bridge.ts serves tripService, userCleanupService and the legacy trips/transports registrars; exchange-rates.bridge.ts was deleted with its last consumers; and the controller adopted budget.dto.ts over the shared schemas, clearing all nine budget allow-list entries — tripService is now unblocked).
  2. tripService (done 2026-07 — the 1121-line hub folded into the wrapper TripsService: generateDays, the updateTrip date-shift transaction, members/guests (#973/#1362), the ICS export, copyTripById and getTripSummary; its six bridge imports became injected services (CollabService/VacayService joined the constructor); the 10-tool trips registrar + 3 trip resources + the trip-summary prompt moved to the decorator-driven trips.mcp.ts (first @Prompt; the static-token deprecation notice rides the registry.attach ctx) and the 3 share-link tools to share.mcp.ts on the canShareTrips predicate; FeedsService and the plugin RPC host inject TripsService; a 3-export trips.bridge.ts serves the legacy prompts registrar and budget.mcp.ts's owner/member seam; todo/share/collab/vacay bridges deleted with their last consumers; the controller adopted trips.dto.ts, clearing all seven trip allow-list entries). placeService followed (done 2026-07 — the 1029-line place core folded into the wrapper PlacesService with its pure half in places.helpers.ts; the 10-tool registrar + the trip-places resource moved to the decorator-driven places.mcp.ts (search_place injects MapsService); TripsService/DaysMcp/BookingImportService/the plugin RPC host all inject it, so the domain needs no bridge at all; the sibling placeEnrichment fold absorbed the last maps.bridge consumer and deleted that bridge too, and the DTO ratchet cleared all seven PlacesController allow-list entries). transitItineraryService followed (done 2026-08 — the last step-4 link and the first pure-helpers relocation with no service fold: the 287-line module is 100% pure (no SQL, no DB, no broadcasts), so the schemas + endpoint/metadata builders moved byte-identical to nest/transit/transit-itinerary.helpers.ts (the schemas must stay module-level plain exports — transit.mcp.ts consumes them inside @Tool decorators, which evaluate before the container exists), its sole consumer transit.mcp.ts was a one-import repoint, and a new 21-case TRANSIT-ITIN-* characterization suite pins the previously untested superRefine error strings and builder output — step 4 is complete). notificationService followed (done 2026-08 — the notifications fan-in, step 3 of the dependency-honest order, deferred while step 4 ran: the send() dispatcher and the inAppNotifications.ts store folded together into the existing wrapper NotificationsService over DatabaseService + RealtimeService; the prefs matrix, smtp/webhook/ntfy transports, channel registry and inAppNotificationActions stay plain infra modules (their registry⇄prefs cycle included); a 1-export notifications.bridge.ts (send) covers scheduler's two cron requires, legacy adminService + memories/{unified,synology}, and the six deliberately-lazy fire-and-forget import() sends in migrated Nest services (path-only repoints); AdminController and the plugin RPC host (24th constructor dep) inject instead; the 5-tool registrar + the notifications-in-app resource moved to the decorator-driven notifications.mcp.ts; the DTO ratchet cleared all five NotificationsController allow-list entries (testNtfyRequestSchema gained .nullable() server/token — the client sends null to mean "use the saved value") — adminService is now unblocked and heads the frontier).

Wave 5 — the heavyweights, last

  1. adminService (done 2026-08 — the last Wave-5 god file: the "11 MCP consumers" figure below was stale, src/mcp/tools/admin.ts has never existed in the repo's history and the plugin host never imported the domain either, so recipe steps 3 and 4 were both no-ops. Ahead of the fold the 11 packing-template functions moved to PackingService, which already owned all three template tables — resolving the admin-2 residual with no bridge, since packing.mcp.ts already injects that service. The 851-line module then folded into the wrapper AdminService over DatabaseService + injected Settings/Addons/Passkey/Packing/Auth/Permissions/Notifications (the auth, notifications and permissions bridge imports all became injections; PERMISSION_ACTIONS stayed a plain const and the mcp/sessionManager deep import kept its anti-cycle comment), with the pure + module-scoped half — compareVersions, utcSuffix, the import-time isDocker probe and the 5-minute version cache — moving to admin.helpers.ts; the cache stays module-scoped so the bridge instance and the container singleton share one GitHub fetch. A 1-export admin.bridge.ts (checkAndNotifyVersion) serves scheduler.ts's cron, the only out-of-container consumer. The DTO ratchet cleared all twenty AdminController allow-list entries, the largest single block, and the module e2e went DI-native (6 cases → 15 over real SQL) — journeyService and oauthService now head the frontier), journeyService/atlasService (done 2026-08 — pulled forward as a zero-dependency frontier member opening the Wave-5 auth chain: the 1612-line core split places-style — the DB half (stats aggregation with its two divergent return shapes, visited countries/regions with the #1490 tombstones + cascade, bucket-list CRUD) folded into the wrapper AtlasService over DatabaseService alone, while the ~750-line pure-geo half (admin0/admin1 boundary stores with the #1576 streaming builders, poly/box indexes, Nominatim geocoding with its shared throttle, the geocode/region caches incl. the import-time unref'd cleanup interval, the geocodingInFlight dedup set) moved verbatim to the plain module nest/atlas/atlas-geo.ts so the multi-MB caches stay process-global across the container + bridge instances; the 10-tool registrar mcp/tools/atlas.ts plus all four atlas resources in mcp/resources.ts moved to the decorator-driven atlas.mcp.ts (the when: atlas-addon gate is parity here — legacy tools/resources gated, REST deliberately doesn't; resources.test.ts retired with its last two cases, moved to the atlas suites); the plugin RPC host swapped its 9 atlas imports for the injected service (23rd constructor dep — journeys is the factory's last legacy domain import); a minimal 2-export atlas.bridge.ts served legacy authService.getTravelStats (the zero-bridge streak ends at five; the bridge died on schedule with the 2026-08 auth fold); the DTO ratchet cleared all three AtlasController allow-list entries)/vacayService (done 2026-07 — pulled forward as a zero-dependency frontier member, see migration-graph.md)/collectionsService (done 2026-08 — pulled forward as a frontier-ready member: the 1024-line saved-places core folded into the wrapper CollectionsService over DatabaseService + PermissionsService + RealtimeService (the checkPermission bridge import and broadcastToUser websocket import became injections; the sendInvite lazy notificationService import() stays, collab precedent; deleteOldCollectionCover re-anchored one directory deeper); the 25-tool registrar mcp/tools/collections.ts moved to the decorator-driven collections.mcp.ts — with NO when: addon gate, preserving the legacy registrar's addon-gate asymmetry — plus a new 23-case tools-collections characterization suite (the registrar was previously untested); the plugin RPC host swapped its 7 collections imports for the injected service (22nd constructor dep); no bridge file anywhere; the DTO ratchet cleared both collections allow-list entries (reorder, delete-many))/mapsService (done 2026-07 — pulled forward as step 4 of the dependency-honest order: the 1429-line geo core folded into the wrapper MapsService, pure helpers to maps.helpers.ts, the 3 geo MCP tools to the decorator-driven maps.mcp.ts (weather/airport tools stay in the legacy registrar), BookingImportService injects it, and a 3-export maps.bridge.ts serves placeEnrichment + the places registrar — transitService → placeService now unblocked)/transitService (done 2026-07 — pulled forward as step 5 of the dependency-honest order: the first fully SQL-free domain fold — the 333-line Transitous/MOTIS proxy became a dep-free TransitService (no DatabaseService); pure deriveTransitStats + mode whitelist + itinerary types moved to transit.helpers.ts so the downstream transitItineraryService needed no bridge; the whole 3-tool registrar moved to the decorator-driven transit.mcp.ts, its days/reservations bridge imports becoming injected DaysService/ReservationsService — zero bridge files; placeService → transitItineraryService followed, both done 2026-07/08), and the auth stack: authService (done 2026-08 — the 1497-line head of the auth → admin → oauth chain folded into the wrapper AuthService over DatabaseService + injected PermissionsService/AtlasService (retiring atlas.bridge.ts on schedule); the pure crypto half (backup-code hash/match/generate, stripUserForClient, key masking, the import-time DUMMY_PASSWORD_HASH timing equaliser + avatarDir mkdir, both documented parity exceptions) moved to the plain module auth.helpers.ts; mfaSetupPending + the per-email reset throttle (with its unref'd cleanup interval) stay module-scoped so the bridge and container instances share them; no MCP registrar existed and the plugin host never imported it, so neither surface changed — but the 15 in-container *.mcp.ts isDemoUser guards now inject AuthService (15 modules import AuthModule), with atlas.mcp.ts alone on the new 8-export auth.bridge.ts because the AuthModule→AtlasModule edge would otherwise close a cycle (places.mcp precedent); the bridge also serves mcp/index.ts token verification, the journey/notifications/transports registrars, and the still-legacy adminService/passkeyService; tests moved with IDs preserved (authService.test.ts → auth.helpers.test.ts, authServiceDb.test.ts → auth.service.test.ts + AUTH-BR-001…007 bridge cases), auth.e2e went DI-native (real bcrypt, real audit rows), oidc.e2e switched its dead path-mock for an instance spy). oidcService followed (done 2026-08 — the first frontier cash-in of the auth fold: the 508-line SSO module folded into the wrapper OidcService over DatabaseService + injected AuthService (the resolveAuthToggles auth.bridge import became the injection); no MCP surface, no plugin-host import, and no bridge at all — nothing outside the container consumes the domain, so the state/auth-code maps, their sweep intervals, and the discovery/JWKS caches became instance state with onModuleDestroy clearing the timers; uuid/bcryptjs lazy requires, the invite_exhausted sentinel transaction and every SQL string relocated verbatim; tests moved with OIDC-SVC IDs preserved, oidc.e2e and the integration suite switched their path mocks for vi.spyOn(app.get(OidcService), …) instance spies). passkeyService followed (done 2026-08 — the last frontier member of that pair: the 364-line WebAuthn module folded into a new PasskeyService in nest/auth/ over DatabaseService + injected AuthService (the three auth.bridge imports resolved to the injection + plain auth.helpers/avatarUrl imports; resolveWebauthnConfig stays a plain helper import); no MCP surface, no plugin-host import, no bridge and no module state — the challenge store is DB-backed — so both in-container consumers just swapped to the injection (PasskeyController, and AdminService.resetUserPasskeys via exports: [PasskeyService] + an AdminModule→AuthModule import); the previously untested service gained a fresh PASSKEY-SVC-001…030 characterization suite with @simplewebauthn/server mocked at the verdict boundary), leaving oauthService — still an unblocked repoint-consumer of the bridge — plus adminService next in the chain. backupService also stays late: it owns closeDb/reinitialize lifecycle, which is deliberately outside the DI wrapper.

Two special cases to keep in mind: airportService is lazily required by db/database.ts at boot (the flight-endpoint backfill) — whenever it migrates, that call should move into Nest's bootstrap instead, or you recreate the circular import; and notifications/notificationService were imported by 6+ other services, so they had to migrate before their dependents — cashed in 2026-08: the fan-in fold above landed exactly that way, send shimmed 1:1 through notifications.bridge.ts for the remaining legacy dependents (adminService, memories) until their own folds.

The through-line: each finished migration lets that domain's tests drop vi.mock('db/database') for overrideProvider(DATABASE_CONNECTION), so the mock seam shrinks wave by wave and disappears with the auth stack. Want me to start on tagService as the pilot?