Re-ran the three-pattern import scan over server/src: authService row deleted, thirteen helper rows updated (nine lose their last legacy importer), oidcService/passkeyService join the ready frontier as auth.bridge repoints, adminService is blocked on notificationService alone, atlas.bridge's death recorded, dependency-honest order step 5 ticked, a post-auth-fold corrections entry added (bridge tax held exactly; the reverse cycle-break, the in-container module fan-out and path-mock rot are now tracked shapes), and the preserved auth quirks listed as trailing fix(server) candidates.
60 KiB
Legacy src/services/ dependency graph
Generated from the actual imports in server/src on 2026-08-01 (after the
authService fold — the Wave-5 chain opener, cashed in the moment the atlas fold
unblocked it, while the notifications fan-in stays the order's official next
step: the 1497-line auth core — the biggest single fold of the migration —
split places-style. The pure crypto half (backup-code hash/match/generate with
the bcrypt/legacy-SHA dual verify, stripUserForClient, key masking, the
import-time DUMMY_PASSWORD_HASH timing equaliser and the avatarDir mkdir,
both documented parity exceptions) moved to the plain module
nest/auth/auth.helpers.ts; the DB half (toggles/app-config, register/login
with the CWE-208 dummy-hash path, MFA setup/enable/disable/verify-login, the
password-reset throttle + token flows with their two db.transaction()
revocation blocks, API keys/settings, travel stats, MCP/ws/resource tokens,
isDemoUser and the two token verifiers) folded into the DI-native
AuthService over DatabaseService + injected PermissionsService (ex
permissions.bridge) + AtlasService (ex atlas.bridge, which died on
schedule — its doc-comment said "delete this file when authService
migrates"). The mfaSetupPending and per-email reset-throttle maps stay
module-scoped (with the import-time unref'd cleanup interval, atlas-geo
class) so the bridge instance and the container singleton share one copy;
require('../../../package.json') and avatarDir re-anchored one directory
deeper — with the two injection swaps, the only non-verbatim lines. No MCP
registrar existed and the plugin host never imported authService, so neither
surface changed — but the fold's real fan-out was in-container: the 15
domain *.mcp.ts demo guards now inject AuthService (15 modules gained an
AuthModule import), with atlas.mcp.ts alone staying on a bridge import
because AuthService injects AtlasService and the reverse module edge would
close an AuthModule↔AtlasModule cycle — the second entry in the
cycle-break-bridge category the place fold opened (assignments.bridge
precedent), except this time on the brand-new bridge. That bridge —
the 8-export nest/auth/auth.bridge.ts — otherwise serves exactly the
predicted tax: mcp/index.ts's token verification
(verifyMcpToken/verifyJwtToken), isDemoUser for the three legacy
registrars (journey/notifications/transports), and
resolveAuthToggles/generateToken/stripUserForClient/avatarUrl for the
still-legacy adminService/oidcService/passkeyService. Tests moved with case
IDs preserved (authService.test.ts → auth.helpers.test.ts,
authServiceDb.test.ts → auth.service.test.ts, which also gained
AUTH-BR-001…007 bridge-delegation cases and AUTH-DB-050…088 — the fold put
~1400 previously ungated lines under the ≥80% src/nest/** branch gate, and
the legacy suite left getAppConfig/resetPassword/the MFA success flows
unpinned); auth.e2e.test.ts converted DI-native (the 30-method whole-module
mock died — login now runs real bcrypt against a factory hash and audit rows
land in a real audit_log), and oidc.e2e.test.ts swapped its silently-dead
services/authService path mock for a vi.spyOn(app.get(AuthService), …)
instance stub. A sibling DTO ratchet cleared all 14 auth allow-list
entries (9 AuthController + 5 AuthPublicController; PasskeyController's
4 stay until passkeyService migrates), adding the six missing request schemas
(mapsKey/apiKeys/settings/appSettings/mfaDisable/resourceToken) to
shared/src/auth/auth.schema.ts. No trailing quirk commit yet — the
faithfully-carried defects are listed under "Quirks preserved" below,
awaiting their own fix(server) pass. Earlier the same day, the
atlasService fold — the second frontier pull-forward of the day, opening the
Wave-5 auth chain: the 1612-line atlas core split two ways, places precedent. The DB
half — the stats aggregation with its two divergent return shapes, visited
countries/regions with the #1490 tombstone/cascade logic, bucket-list CRUD —
folded into the DI-native AtlasService over DatabaseService alone (no
broadcasts anywhere: atlas rows are uid-scoped). The ~750-line pure-geo half —
the bundled admin0/admin1 boundary stores with their #1576 OOM-shaped
streaming builders, the point-in-polygon country/region indexes, Nominatim
geocoding with its shared ≥1.1s throttle, the 50k geocode cache with its
import-time unref'd cleanup interval, and the geocodingInFlight dedup set —
moved verbatim to the plain module nest/atlas/atlas-geo.ts (maps.helpers
class), so those 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 (trek://bucket-list,
trek://visited-countries, trek://atlas/stats, trek://atlas/regions)
moved onto the decorator registry as atlas.mcp.ts — here the when:
atlas-addon gate IS parity (unlike collections, the legacy registrar and
resources both gated on the addon while the REST controller deliberately does
not), and that emptied mcp/resources.ts's test suite: resources.test.ts
retired with its last two cases. The plugin RPC host swapped its 9 atlas
imports for the injected AtlasService (its 23rd constructor dep) — journeys
is now the factory's last legacy domain import. And the zero-bridge streak
ends at five: authService.getTravelStats (legacy, Wave-5) consumes
getCountryFromCoords + getHiddenCountries, so a minimal 2-export
atlas.bridge.ts exists for that one edge — getCountryFromCoords
re-exported straight from atlas-geo, getHiddenCountries over the bridge
instance — and it died with the auth fold later the same day. A sibling DTO ratchet cleared
all three AtlasController body-contract allow-list entries
(mark-region + bucket create/update), trading the hand-rolled
'name and country_code are required' 400 for the pipe envelope while the
whitespace-only bucket name keeps its legacy 'Name is required' trim guard.
Earlier the same day, the collectionsService fold —
the biggest single fold yet, taken off the ready
frontier while the notifications fan-in stays the order's official next step:
the 1024-line saved-places core (visibility/roles, the collection-scoped
dedup, saved-places CRUD, copy-to-trip with the ratings filters, labels, the
fusion invitation state machine) folded into the DI-native CollectionsService
over DatabaseService + PermissionsService + RealtimeService — the
permissions.bridge and websocket broadcastToUser imports became injections,
the placeImage helper import stays plain, the sendInvite call-time
import() of notificationService stays lazily as-is (collab precedent), and
deleteOldCollectionCover re-anchored its __dirname path one directory
deeper. The whole 25-tool mcp/tools/collections.ts registrar moved onto the
decorator registry as collections.mcp.ts — deliberately with no when:
addon gate, because the legacy registrar registered unconditionally while
REST (CollectionsAddonGuard) and the plugin host (requireAddon) gate on the
addon; that asymmetry is now pinned as a characterization case. The plugin RPC
host swapped its 7 collections imports for the injected CollectionsService
(its 22nd constructor dep), so the domain needed no bridge at all — the
fifth consecutive zero-bridge fold. The legacy registrar had no tool-level
tests; a new 23-case tools-collections suite now covers payloads, error
texts, scope gating and the addon-gate absence, and the moved 47-case
COLLECTIONS-SVC-* suite gained two membership-lookup cases. A sibling DTO
ratchet cleared both collections body-contract allow-list entries (reorder,
delete-many), retiring the last two hand-rolled 400 strings of the domain in
favour of the pipe envelope (places precedent). A day earlier, the
transitItineraryService relocation closed step 4 — the fourth and final link
of that chain, and the first pure-helpers relocation with no
service fold at all: the 287-line module is 100% pure — no SQL, no DB, no
broadcasts — so its Zod itinerary 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({ inputSchema }) decorators, which evaluate at module load before any
container exists), its sole consumer — the in-container transit.mcp.ts — was
a one-import repoint, and a new 21-case TRANSIT-ITIN-* characterization suite
pins the previously untested superRefine error strings, ?? time fallbacks,
coordinate tolerances and builder output. The
notifications fan-in heads the frontier. Before that, the placeService
migration (2026-07-30): the 1029-line place core (CRUD + ratings SQL,
the GPX/KML/KMZ importers, the Google/Naver list importers) folded into the
DI-native PlacesService; the pure pieces — the frozen XML parsers, the KMZ
unpacker, the dedup predicates, the Google hex-id parsers, reclaimPhotoCache
— into nest/places/places.helpers.ts (maps.helpers precedent); the whole
10-tool mcp/tools/places.ts registrar plus the
trek://trips/{tripId}/places resource onto the decorator registry
(places.mcp.ts, with search_place coming along because its gate is
places:read and now injecting MapsService); TripsService, DaysMcp,
BookingImportService and the plugin RPC host (its 21st constructor dep) all
inject PlacesService, so the domain needs no bridge at all. The sibling
placeEnrichment fold went further: that helper's DB/websocket/Maps half became
PlacesService methods over the injected
DatabaseService/RealtimeService/MapsService and its pure match selector
joined places.helpers.ts — which retired maps.bridge.ts with its last
consumer. A DTO ratchet commit cleared all seven PlacesController body
allow-list entries, and a trailing fix(server) commit repaired four verified
defects the relocation had faithfully carried (see "Quirks fixed" below).
Earlier the same week, the transitService migration opened the tail of step 4
(the 333-line Transitous/MOTIS proxy into the dep-free TransitService, pure
stats/types into nest/transit/transit.helpers.ts, the 3-tool registrar onto
the decorator registry — zero bridge files, registrar deleted); before that the
mapsService fold (the 1429-line geo core into MapsService, pure helpers into
maps.helpers.ts, the 3 geo MCP tools onto the registry), and before that the
tripService fold completing step 2 (the 1121-line hub into TripsService, its
MCP surface onto trips.mcp.ts/share.mcp.ts, the plugin host injecting it,
and the bridge cascade — todo.bridge, share.bridge, collab.bridge and
vacay.bridge deleted with their last consumers, days/budget pruned to their
survivors). Earlier context: the budgetService migration (step 1) and the
Phase 0 quick wins: getAppUrl/getMcpSafeUrl → src/app-config deleted the
fake → notifications edges for the identity/MCP stack and freed mapsService;
the addon-enablement reads moved from adminService into nest/addons/
(addons.bridge.ts for out-of-container consumers), collapsing the admin
god-file's ~27-consumer fan-in to the admin routes + one admin-2 residual
and freeing oauthService.
This regeneration parses static from '...', require('...') and dynamic
import('...') specifiers, so the lazy edges the earlier grep-based analysis
missed (scheduler jobs, the db-boot airport backfill, the fire-and-forget
notification sends) are included. Regenerate any time by re-running that
three-pattern import scan over server/src (a throwaway script suffices; the
patterns are the whole trick).
How to read it:
- imports (services/) — what the legacy file pulls from other legacy files. A service is
migration-ready when this column contains only helpers (see classification below) and/or
tripAccess(Wave-2 "don't migrate, delete": absorb intoDatabaseService.canAccessTrip). Edges tagged (lazy) areimport()/require()at call time — they never block a migration, because a migrated Nest service can keep the same lazy import until the target domain migrates (collab/packing/trips/reservations/vacay all do exactly that fornotificationService). - imported by (services/) — legacy files that would need a bridge or repoint when this one migrates (a legacy module can't inject).
- nest consumers — in-container consumers: repoint to the injected service
(
exports: [XService]+ module import), never a bridge. - out-of-container consumers —
mcp/,scheduler.ts,websocket.ts,db/,middleware/,systemNotices/,index.ts: these are the bridge pressure (todo.bridge.ts precedent).
Node classification
- Already DI-native (legacy file deleted): tags, categories, todo, packing, day-notes,
trip-invite, assignments, share, settings, files, collab, vacay, reservations, day,
permissions (module-scoped cache retained on purpose — the bridge and DI instances share
one invalidation), audit (the
writeAuditinjectable;client-ip.tsand the deliberately side-effectfulaudit-log.logger.tsstay plain modules insidenest/audit/), exchange-rates (the exchangeRateService fold intonest/budget/as the dep-freeExchangeRatesService— module-scoped rate cache retained on purpose, permissions-style, so out-of-container instances and the DI singleton share one cached upstream feed; itsexchange-rates.bridgewas deleted with the budgetService migration), budget (the 755-line money core folded intoBudgetService;budget.bridge.tsis down to the two exports still consumed outside the container — userCleanupService and the legacy transports registrar), trip (the 1121-line hub folded intoTripsService; its six bridge imports became injected services, and the fold deletedtodo.bridge,share.bridge,collab.bridgeandvacay.bridgewith their last consumers; a 3-exporttrips.bridge.tsserves the legacy prompts registrar's getTripSummary andbudget.mcp.ts's getTripOwner/listMembers seam — injecting there would need a forwardRef'd TripsModule↔BudgetModule cycle;days.bridge.tsshrank to getDay/listDays — since the transit fold, for the transports registrar alone), maps (the 1429-line geo core folded intoMapsService; pure helpers —buildUserAgent, the opening-hours parsers, POI categories, Overpass endpoint resolution — are plain exports innest/maps/maps.helpers.ts(the DI-native TransitService imports its UA from there); the module-scoped POI cache / photo-fetch semaphore / frozen Overpass mirrors stay module-scoped on purpose so any out-of-container instance and the DI singleton share them;maps.bridge.tsis gone — the place fold absorbed both of its consumers, theplaceEnrichmenthelper and the places MCP registrar, and PlacesService / PlacesMcp / BookingImportService now injectMapsServicedirectly), transit (the first fully SQL-free domain fold: the Transitous/MOTIS proxy became the dep-freeTransitService— response cache, frozen-at-importTRANSIT_API_BASEand lazy User-Agent memo stay module-scoped on purpose; the purederiveTransitStats+SCHEDULED_TRANSIT_MODES+ itinerary types are plain exports innest/transit/transit.helpers.ts(maps.helpers precedent), consumed since the 2026-08 relocation by the colocatedtransit-itinerary.helpers.ts; the 3-tool registrar moved totransit.mcp.tsand was deleted — no bridge ever existed for this domain), transit-itinerary (the pure-helpers relocation closing step 4: schemas + endpoint/metadata builders moved byte-identical from the legacytransitItineraryServicetonest/transit/transit-itinerary.helpers.ts— plain exports, no service, no bridge, no DTO, no plugin-host work; thedistanceService/timezoneServicehelper imports stay plain, theEndpointInputtype import repointed fromreservations.bridgetoreservations.servicedirectly), place (the 1029-line place core folded intoPlacesService, its pure half intonest/places/places.helpers.ts, and the whole MCP surface — 10 tools + the trip-places resource — ontoplaces.mcp.ts; theplaceEnrichmenthelper was absorbed in the same wave, takingmaps.bridge.tswith it. Zero bridge files: every consumer is in-container. The twoassignments.bridgeimports thatplaces.mcp.tskeeps are a deliberate cycle break — AssignmentsModule imports DaysModule and DaysModule imports PlacesModule fordays.mcp.ts's place creation, so injecting AssignmentsService there would close DaysModule → PlacesModule → AssignmentsModule → DaysModule; the same seamreservations.mcp.tsuses and the same tradetrips.bridge.tsdocuments), collections (the 1024-line saved-places core folded intoCollectionsServiceoverDatabaseService/PermissionsService/RealtimeService; the 25-tool registrar moved tocollections.mcp.tswithout awhen:addon gate — the legacy registrar's addon-gate asymmetry, preserved and test-pinned; the plugin host injects the service as its 22nd constructor dep; thesendInvitelazy notificationServiceimport()stays call-time (collab precedent); the deadbuildDedupSetmodule helper was dropped in the move — the only non-verbatim line. Zero bridge files: every consumer is in-container), atlas (the 1612-line core split places-style: the DB half intoAtlasServiceoverDatabaseServicealone, the pure-geo half — boundary stores, poly/box indexes, Nominatim geocoding, all the module-scoped caches including the import-time unref'd cleanup interval — verbatim into the plain modulenest/atlas/atlas-geo.tsso the multi-MB caches stay process-global across every instance (#1576); the 10-tool registrar + all four resources ontoatlas.mcp.tswith thewhen:atlas-addon gate that IS parity here; the plugin host injects the service as its 23rd constructor dep; its 2-exportatlas.bridge.tsserved legacyauthService.getTravelStatsand died on schedule with the auth fold), auth (the 1497-line credential/MFA/reset/settings core folded intoAuthServiceoverDatabaseService+ injectedPermissionsService+AtlasService; the pure crypto half — backup-code hashing/matching,stripUserForClient, key masking, the import-timeDUMMY_PASSWORD_HASHandavatarDirmkdir parity exceptions — is plain exports innest/auth/auth.helpers.ts;mfaSetupPending+ the per-email reset-throttle map (and its unref'd interval) stay module-scoped so the bridge and container instances share them; one bridge, the 8-exportauth.bridge.ts— formcp/index.tstoken verification, the three legacy registrars'isDemoUser, the still-legacy adminService/oidcService/passkeyService, and the one in-container cycle-break consumeratlas.mcp.ts(AuthService injects AtlasService, so AtlasModule cannot import AuthModule — assignments.bridge precedent); the other 15 domain*.mcp.tsdemo guards injectAuthService). - Domain migration targets (the wave material): adminService, airportService, backupService, journeyService, journeyShareService, notificationService, oauthService, oidcService, passkeyService, weatherService, wikiService.
- Cross-cutting Wave-2 targets: permissions and auditLog are done (2026-07) — see the DI-native list above; only tripAccess remains (delete, don't migrate).
- Helpers that stay as plain modules (pure/infra, not wave material): avatarUrl,
queryHelpers, conflictResult, cookie, demo, distanceService, ephemeralTokens, apiKeyCrypto,
mfaCrypto, passwordPolicy, webauthnConfig, timezoneService, llmConfig, kmlImport, placeImage,
placePhotoCache, unsplashService, userCleanupService,
inAppNotifications, inAppNotificationActions, notificationPreferencesService, notifications
(+
notifications/registry),memories/cluster,airtrail/cluster. Several of these are themselves candidates to fold into a domain service when its domain migrates — and two already have: exchangeRateService → budget (2026-07) and placeEnrichment → place (2026-07), the latter also retiring a bridge. The four still reached only fromnest/places/*(kmlImport, placeImage, placePhotoCache, unsplashService) are the next obvious fold candidates, but none of them blocks anything.
Domain-level graph (edges = "must migrate first, or bridge"; dotted = lazy, non-blocking)
flowchart TD
classDef ready fill:#2e7d32,color:#fff
classDef blocked fill:#b71c1c,color:#fff
classDef hub fill:#e65100,color:#fff
classDef infra fill:#455a64,color:#fff
subgraph frontier["READY FRONTIER (no unmigrated domain deps)"]
weather[weatherService]:::ready
airport["airportService (boot special case)"]:::ready
wiki[wikiService]:::ready
oauth[oauthService]:::ready
oidc["oidcService (frontier since the 2026-08 auth fold)"]:::ready
passkey["passkeyService (frontier since the 2026-08 auth fold)"]:::ready
end
notifSvc[notificationService]:::blocked
admin[adminService]:::blocked
journey[journeyService]:::blocked
journeyShare[journeyShareService]:::blocked
backup["backupService (last by design)"]:::infra
notifCluster["notifications cluster<br/>(notifications + prefs + inApp + channelRegistry)"]:::infra
memories["memories/ cluster"]:::infra
cleanup[userCleanupService]:::infra
notifSvc --> notifCluster
admin --> notifSvc & cleanup
journey --> memories
journeyShare --> journey
memories --> notifSvc & admin
(The authService node is gone since the 2026-08-01 fold — the Wave-5 chain
opener landed the same day its atlas blocker cleared. The three --> auth
edges (admin/oidc/passkey, all resolveAuthToggles-class imports, passkey
also generateToken/stripUserForClient/avatarUrl) became import-path-only
repoints onto the 8-export nest/auth/auth.bridge (todo-bridge precedent),
which puts oidcService and passkeyService on the frontier — their only
other legacy deps are helpers (apiKeyCrypto/tripMembership,
webauthnConfig) — while adminService stays blocked on its hard
notificationService import alone. userCleanupService is infra, not a
blocker (helpers never block). The atlasService node left the frontier
earlier the same day as the first fold whose legacy dependent outlived it:
its former auth --> atlas bridge edge died with the auth fold, and
atlas.bridge.ts with it.
The collectionsService node is gone since the 2026-08-01 fold — its dotted
call-time import() edge to notificationService survives inside the DI-native
CollectionsService, exactly like the identical lazy sends in the
already-migrated collab/packing/trips/reservations/vacay services, so it never
appears here again; its permissions.bridge repoint became an injected
PermissionsService and its placeImage import stays a plain helper import.
The transitItineraryService node is gone since the 2026-08-01 relocation —
its transitService edge had already become the pure
nest/transit/transit.helpers import when the transit domain went DI-native,
and the relocation moved the whole module into nest/transit/ as
transit-itinerary.helpers.ts, so nothing was left to bridge or repoint beyond
the one transit.mcp.ts import. The placeService node is gone since the
2026-07-30 fold — its legacy imports were
all helpers, and the former placeService → mapsService edge (via placeEnrichment)
died with the helper rather than becoming a repoint.
notificationService → notifications cluster is a hard import — the former
mapsService/transitService/webauthnConfig/ oauthService → notifications edges were only getAppUrl/getMcpSafeUrl and died with the
Phase 0 move to src/app-config, which had put mapsService on the frontier. memories/ ↔ admin/notificationService edges make the
journey/memories corner tangle with the admin corner. The former
auth/collections/backup → permissions and notifSvc/oauth → auditLog edges are gone since the
2026-07 Wave-2 pair: the permissions consumers repointed to nest/permissions/permissions.bridge,
the writeAudit consumers to nest/audit/audit.bridge, and the log*-only consumers to the plain
nest/audit/audit-log.logger — none of them block a migration anymore. The
tripService hub node is gone since the 2026-07 trip fold — the DI-native
TripsService injects its former bridge targets and keeps the legacy
avatarUrl/timezoneService/userCleanupService helpers as plain imports (helpers
never block).)
Full adjacency table
| service | imports (services/) | imported by (services/) | nest consumers | out-of-container consumers |
|---|---|---|---|---|
adminService |
apiKeyCrypto, avatarUrl, llmConfig, memories/helpersService, notificationService, passwordPolicy, userCleanupService (+ permissions.bridge, addons.bridge, auth.bridge) |
(none) | nest/admin/admin.service.ts, nest/packing/packing.mcp.ts (deletePackingTemplate, the admin-2 residual) |
scheduler.ts (lazy) |
airportService |
(none) | (none) | nest/airports/airports.service.ts, nest/booking-import/kitinerary-mapper.ts | db/database.ts (lazy boot backfill), mcp/tools/mapsWeather.ts, mcp/tools/transports.ts |
apiKeyCrypto |
(none) | adminService, airtrail/airtrailService, llmConfig, memories/helpersService, memories/immichService, memories/photoResolverService, memories/synologyService, memories/unifiedService, notifications, oidcService, unsplashService | nest/auth/auth.helpers.ts, nest/auth/auth.service.ts, nest/maps/maps.service.ts, nest/plugins/plugin-oauth.service.ts, nest/plugins/plugin-runtime.service.ts, nest/plugins/plugins.service.ts, nest/settings/settings.service.ts | db/migrations.ts |
avatarUrl |
(none) | adminService, inAppNotifications, journeyService | nest/auth/auth.bridge.ts, nest/auth/auth.service.ts, nest/budget/budget.service.ts, nest/collab/collab.service.ts, nest/files/files.service.ts, nest/packing/packing.service.ts, nest/reservations/reservations.service.ts, nest/trips/trips.service.ts | (none) |
backupService |
(none — permissions.bridge, plugin backup/paths infra only) |
(none) | nest/backup/backup.controller.ts, nest/backup/backup.service.ts | scheduler.ts (lazy) |
conflictResult |
(none) | (none) | nest/packing/packing.controller.ts, nest/packing/packing.service.ts, nest/places/places.controller.ts, nest/places/places.service.ts, nest/plugins/host/plugin-host-deps.factory.ts | (none) |
cookie |
(none) | (none) | nest/auth/auth-public.controller.ts, nest/auth/auth.service.ts, nest/auth/passkey.controller.ts, nest/oidc/oidc.controller.ts, nest/oidc/oidc.service.ts | (none) |
demo |
(none) | (none) | nest/auth/auth.controller.ts, nest/auth/auth.service.ts, nest/collections/collections.controller.ts, nest/files/files.controller.ts, nest/places/places.controller.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trips/trips.controller.ts | middleware/auth.ts, middleware/mfaPolicy.ts |
distanceService |
(none) | (none) | nest/auth/auth.service.ts, nest/transit/transit-itinerary.helpers.ts | (none) |
ephemeralTokens |
(none) | (none) | nest/auth/auth.service.ts, nest/files/files.service.ts | index.ts, websocket.ts |
inAppNotificationActions |
(none) | inAppNotifications | (none) | (none) |
inAppNotifications |
avatarUrl, inAppNotificationActions, notificationPreferencesService | notificationService | nest/notifications/notifications.service.ts | mcp/resources.ts, mcp/tools/notifications.ts |
journeyService |
avatarUrl, memories/photoResolverService | journeyShareService | nest/assignments/assignments.service.ts, nest/journey/journey.service.ts, nest/places/places.mcp.ts, nest/places/places.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/plugins/journal-entry-rows.controller.ts | mcp/resources.ts, mcp/tools/journey.ts |
journeyShareService |
journeyService | (none) | nest/journey/journey.service.ts | mcp/tools/journey.ts |
kmlImport |
(none) | (none) | nest/places/places.helpers.ts, nest/places/places.service.ts | (none) |
llmConfig |
apiKeyCrypto | adminService | nest/llm-parse/llm-client.factory.ts, nest/llm-parse/llm-config.resolver.ts | (none) |
mfaCrypto |
(none) | (none) | nest/auth/auth.service.ts | (none) |
notificationPreferencesService |
notifications, notifications/builtins, notifications/channelRegistry | inAppNotifications, notificationService, notifications, notifications/channelRegistry | nest/admin/admin.service.ts, nest/notifications/notifications.service.ts, nest/plugins/install/manifest.ts | (none) |
notifications |
apiKeyCrypto, notificationPreferencesService (+ audit-log.logger) |
notificationPreferencesService, notificationService, notifications/builtins | nest/auth/auth.service.ts, nest/notifications/notifications.service.ts | (none) |
notificationService |
inAppNotifications, notificationPreferencesService, notifications, notifications/channelRegistry (+ audit-log.logger) |
adminService, memories/synologyService, memories/unifiedService | nest/admin/admin.controller.ts, nest/collab/collab.service.ts, nest/collections/collections.service.ts (lazy), nest/packing/packing.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/reservations/reservations.service.ts, nest/trips/trips.service.ts, nest/vacay/vacay.service.ts | scheduler.ts (lazy) |
oauthService |
(none — addons.bridge + audit.bridge only) |
(none) | nest/oauth/oauth-api.controller.ts, nest/oauth/oauth.service.ts | mcp/index.ts, mcp/oauthProvider.ts |
oidcService |
apiKeyCrypto, tripMembership (+ auth.bridge) |
(none) | nest/oidc/oidc.service.ts | (none) |
passkeyService |
webauthnConfig (+ auth.bridge) |
(none) | nest/admin/admin.service.ts, nest/auth/passkey.controller.ts | (none) |
passwordPolicy |
(none) | adminService | nest/auth/auth.service.ts | (none) |
placeImage |
(none) | (none) | nest/collections/collections.controller.ts, nest/collections/collections.service.ts, nest/common/place-image-upload.ts, nest/places/places.controller.ts, nest/places/places.service.ts | (none) |
placePhotoCache |
(none) | (none) | nest/maps/maps.service.ts, nest/places/places.helpers.ts, nest/share/share.service.ts | scheduler.ts (lazy) |
queryHelpers |
(none) | (none) | nest/assignments/assignments.service.ts, nest/days/days.service.ts, nest/places/places.service.ts, nest/share/share.service.ts | (none) |
timezoneService |
(none) | airtrail/airtrailMapper | nest/transit/transit-itinerary.helpers.ts, nest/trips/trips.service.ts | (none) |
tripAccess |
(none) | (none) | nest/booking-import/booking-import.service.ts, nest/budget/budget.service.ts, nest/collab/collab.service.ts, nest/days/day-notes.service.ts, nest/integrations/airtrail-import.controller.ts, nest/packing/packing.service.ts, nest/reservations/reservations.service.ts, nest/todo/todo.service.ts | (none) |
tripMembership |
(none) | oidcService | nest/auth/auth.service.ts, nest/plugins/host/plugin-host-deps.factory.ts, nest/trip-invite/trip-invite.service.ts | (none) |
unsplashService |
apiKeyCrypto | (none) | nest/places/places.service.ts, nest/trips/trips.controller.ts, nest/trips/trips.service.ts | (none) |
userCleanupService |
(none — budget.bridge, plugin paths infra only) |
adminService | nest/auth/auth.service.ts, nest/trips/trips.service.ts | (none) |
weatherService |
(none) | (none) | nest/plugins/host/plugin-host-deps.factory.ts, nest/weather/weather.controller.ts, nest/weather/weather.service.ts | mcp/tools/mapsWeather.ts |
webauthnConfig |
(none) | passkeyService | nest/auth/auth.service.ts | (none) |
wikiService |
(none) | (none) | nest/help/help.controller.ts | (none) |
Subdirectory clusters
notifications/:channelRegistry⇄notificationPreferencesService(cycle),builtins → notifications + channelRegistry, andnotificationPreferencesService → builtins(registering the built-in channels closes a second loop). Migrates as one unit withnotifications.ts/notificationService.memories/:helpersService(base) ← immich/synology/unified/photoResolver;photoResolverServiceis the seamjourneyServiceconsumes (it also pulls immich/synology/thumbnail/trekPhotoCache— the latter is swept byscheduler.ts);synology/unified → notificationServicecouples this cluster to the admin corner (thumbnailService's adminService edge was onlyisAddonEnabled— nowaddons.bridge);immichServicewrites audits vianest/audit/audit.bridge.airtrail/:airtrailClient(base) ← mapper ← service ← import/sync;import/syncconsume (since the 2026-07 reservations fold) thenest/reservations/reservations.bridgeinstead of the deletedreservationService(their adminService edge was onlyisAddonEnabled— nowaddons.bridge); since the auditLog fold,airtrailServicewrites audits vianest/audit/audit.bridgeandairtrailSynclogs via the plainnest/audit/audit-log.logger(same split inmemories/immichService→ bridge).
Decoding "what's next"
Ready frontier (all legacy deps are helpers, tripAccess, or lazy sends):
With step 4 closed by the 2026-08 transit-itinerary relocation, the pick is no
longer on this table: the dependency-honest order's next step is the
notifications cluster → notificationService fan-in (step 3, deferred
while step 4 ran) — it must precede its admin/memories dependents. The
frontier candidates below can still go any time:
| Candidate | Why now / why not | Bridge tax (legacy dependents + out-of-container) |
|---|---|---|
| oidcService / passkeyService | Frontier since the 2026-08 auth fold — their authService edges became auth.bridge repoints, leaving only helpers (apiKeyCrypto/tripMembership, webauthnConfig); both fold into existing thin Nest wrappers (OidcService already injects AuthService; PasskeyController still import *s the legacy module). The coherence order rides them with the auth chain, before/with adminService |
oidc: none; passkey: none (both are leaves — nothing legacy imports them) |
| oauthService | Dependency-free since the Phase 0 addons extraction (adminService edge was only isAddonEnabled → addons.bridge) — but the coherence order keeps it after admin so the mcp/oauthProvider.ts merge (mcp-2) lands with it |
mcp/index.ts, mcp/oauthProvider.ts |
| weatherService / wikiService / airportService | Independent leaves; airport has the db/database.ts boot lazy-require special case |
little / none |
Blocked, and by what (shortest unblock path):
notificationService← notifications cluster (its auditLog edge is now the plainaudit-log.loggerimport — gone as a blocker since the 2026-07 Wave-2 pair).adminServicecorner:authServiceis DONE (2026-08 — the fold repointed adminService'sresolveAuthTogglesimport toauth.bridge), so adminService is blocked by its hardnotificationServiceimport alone (userCleanupService is a helper — never blocks).oauthServiceis dependency-free since the Phase 0 addons extraction (its adminService edge was onlyisAddonEnabled, nowaddons.bridge), but the coherence order stays auth → admin → oauth so themcp/oauthProvider.tsmerge (mcp-2) lands after admin (oidc/passkey are frontier-ready bridge consumers now; permissions done 2026-07, atlas + auth done 2026-08).journeyService←memories/cluster (which itself touches admin + notificationService) →journeyShareServiceafter. Note the place fold added two more in-container journeyService consumers (places.service.ts's hooks andplaces.mcp.ts's skeleton reconcile), so this migration's repoint list grew by two.backupService: unblocked since the permissions fold (its edge is apermissions.bridgerepoint now) but stays last by design (owns the closeDb/reinitialize lifecycle and the plugin backup/paths infra).collectionsServicecashed in the same unblock on 2026-08-01 — done.
Dependency-honest order (each step's deps are already done at that point;
vacayService, reservationService (the residue fold), dayService and the
Wave-2 permissions + auditLog pair were the first frontier picks — all done
2026-07, after collabService completed Wave 3):
exchangeRateServicefold →budgetService(both done 2026-07 —ExchangeRatesServicethen the fullBudgetServicefold innest/budget/;userCleanupServiceis free, repointed tobudget.bridge)tripService(done 2026-07 — the hub folded intoTripsService; the trips/share MCP surfaces moved to the decorator registry, the plugin host injects it, and the todo/share/collab/vacay bridges died with their last consumers)- notifications cluster →
notificationService mapsService(done 2026-07 — the geo core folded intoMapsService, geo MCP tools onto the decorator registry, BookingImportService injects it, and a 3-exportmaps.bridgecovered placeEnrichment + the places registrar) →transitService(done 2026-07 — the SQL-free proxy folded into the dep-freeTransitService, pure stats/types totransit.helpers.ts, the whole 3-tool registrar totransit.mcp.ts; no bridge) →placeService(done 2026-07 — the place core folded intoPlacesService, the pure half toplaces.helpers.ts, the 10-tool registrar + trip-places resource toplaces.mcp.ts; TripsService/DaysMcp/BookingImportService/the plugin host inject it, so no bridge; the siblingplaceEnrichmentfold deletedmaps.bridgewith its last consumer, and the DTO ratchet cleared all sevenPlacesControllerallow-list entries) →transitItineraryService(done 2026-08 — the last link: the 100%-pure module relocated byte-identical tonest/transit/transit-itinerary.helpers.tsas plain exports,transit.mcp.tsrepointed, no service/bridge/DTO work, a new 21-caseTRANSIT-ITIN-*characterization suite — step 4 complete)atlasService(done 2026-08 — the frontier pull-forward that opened this chain: the DB half intoAtlasService, the pure-geo half into the plainatlas-geo.ts, the 10-tool registrar + 4 resources ontoatlas.mcp.ts, the plugin host's 23rd constructor dep, the 2-exportatlas.bridge.tsfor authService, and the DTO ratchet clearing all three allow-list entries) →authService(done 2026-08 — the 1497-line chain opener: the crypto half to the plainauth.helpers.ts, the DB half intoAuthServiceover DatabaseService + injected Permissions/AtlasService,atlas.bridgedeleted on schedule, the 8-exportauth.bridge.tsfor the mcp transport + three legacy registrars + admin/oidc/passkey, 15*.mcp.tsdemo guards injecting AuthService withatlas.mcp.tsas the documented cycle-break exception, and the DTO ratchet clearing all 14 auth allow-list entries) → oidc/passkey →adminService→oauthServicememories/cluster →journeyService→journeyShareService;collectionsService(done 2026-08-01 — taken off the frontier ahead of step 3: the 1024-line fold intoCollectionsService, the 25-tool registrar ontocollections.mcp.ts, the plugin host injecting it as its 22nd constructor dep, no bridge, and the DTO ratchet clearing both allow-list entries)- Independent any time:
weatherService,wikiService,airportService(move the boot backfill into Nest bootstrap when you do it);backupServicelast
Corrections to migrate.md this graph surfaced:
-
reservationServicedid not importbudgetService/dayService— the claimed Wave-4 ordering constraint didn't exist at the service layer; its legacy remainder was frontier-ready. Borne out by the 2026-07 fold: the budget/day coupling lives in the Nest wrapper's budget-sync seam and the MCP surface, which keep their legacy imports until those domains migrate. -
Every remaining Wave-3/4 domain had legacy
tripServiceas a dependent — each migration before tripService paid a small bridge/repoint tax the files migration didn't have. Resolved by the 2026-07 trip fold: that recurring tax is gone, and the fold cashed in the accumulated bridges (todo/share/collab/vacay deleted, days/budget pruned). -
(2026-07-28 regeneration) The earlier grep-based analysis only saw static imports, so it missed every lazy edge: the scheduler's
require()jobs (admin, notificationService, placePhotoCache, airtrailSync, trekPhotoCache), thedb/database.tsboot backfill's airportService require,index.ts→ ephemeralTokens,systemNotices/conditions.ts→ adminService, andcollectionsService's call-timeimport()of notificationService. It also under-reportedtripService's bridge repoints (six, not three: budget, collab, days, packing, reservations, vacay). None of this changes the order — lazy edges don't block — but the bridge-pressure column was undercounting scheduler/system-notice consumers. -
(2026-07-29 regeneration, post trip fold) The predicted bridge tax was right in shape but the fold went further than a bridge: the whole trips MCP surface (10 tools + 3 resources
- the first
@Prompt) and the 3 share-link tools moved onto the decorator registry, so the only bridge left is the 3-exporttrips.bridge.tsfor the legacy prompts registrar andbudget.mcp.ts's owner/member seam.verifyTripAccess'stripAccessre-export died with the legacy file, leavingservices/tripAccesswith zero legacy importers — it is now purely Nest-consumed (Wave-2 "delete, don't migrate" is down to those repoints).
- the first
-
(2026-07-29 regeneration, post maps fold) The predicted bridge tax over-counted:
transitServiceneeded no bridge at all — its only import was the purebuildUserAgent, which now lives inmaps.helpers.tsas a plain export (helpers never block), and after the geo tools moved onto the decorator registrymcp/tools/mapsWeather.tsno longer consumes the maps domain (the registrar file survives for its weather + airport tools; itssearchPlacesimport turned out to be dead code and died with the prune). The bridge is 3 exports forplaceEnrichment+mcp/tools/places.ts, both of which the placeService migration will absorb. Step 4's remaining chain — transitService → placeService — is now entirely frontier-ready. -
(2026-07-29 regeneration, post transit fold) The "no bridge at all" prediction held — and the fold went further than predicted in the other direction: the whole 3-tool registrar moved onto the decorator registry, including
create_transit_journey, which never imported transitService — its days/reservations bridge imports became injectedDaysService/ReservationsServiceand its rawcanAccessTripthe injectedDatabaseService.mcp/tools/transit.tswas deleted outright (no mapsWeather-style survivor),days.bridge.tsis down to its last consumer (the transports registrar), andreservations.bridge.tslost two of its remaining out-of-container call sites. ThetransitItineraryService → transitServiceedge became the puretransit.helpersimport, and its only remaining consumer is the in-containertransit.mcp.ts— so transitItineraryService joined the frontier with a bridge tax of zero, ahead of the order's placeService step. -
(2026-07-30 regeneration, post place fold) The prediction that placeService "will absorb" both
maps.bridgeconsumers held exactly:placeEnrichmentfolded intoPlacesServiceand the places registrar becameplaces.mcp.tsinjectingMapsService, somaps.bridge.tswas deleted and the place domain needed no bridge of its own — the fourth consecutive fold to end with zero new bridge files. Two things the earlier analysis did not predict: (a)search_placetravelled with the places registrar rather than joiningmaps.mcp.ts, because its gate isplaces:readand not the read-onlygeogroup — the same gate-follows-the-tool rule that sent the share-link tools toshare.mcp.tsduring the trip fold; and (b) the fold created a module cycle risk the recipe had not hit before — DaysModule must import PlacesModule fordays.mcp.ts's place creation, while AssignmentsModule already imports DaysModule, soplaces.mcp.tskeeps its twoassignments.bridgeimports rather than injecting AssignmentsService. That leavesassignments.bridge.tswith only in-container consumers (places.mcp.ts + reservations.mcp.ts) — a bridge kept purely as a cycle break, which is a new category the classification above should track. The four helpers now reached only fromnest/places/*(kmlImport, placeImage, placePhotoCache, unsplashService) are the residue this fold could have absorbed and did not; none of them blocks anything, so they stay as plain modules for now. -
(2026-08-01 regeneration, post transit-itinerary relocation) The "bridge tax zero" and "the fold is a repoint" predictions held exactly — and the shape went one step further than the frontier row implied: there was no fold at all. The module turned out to be 100% pure (no SQL, no DB access, no broadcasts), so every recipe step except the move itself was a no-op, and a hard constraint settled the target shape: the Zod schemas are consumed inside
transit.mcp.ts's@Tool({ inputSchema })decorators, which evaluate at module load — before any container exists — so they must stay module-level plain exports (maps.helpers precedent), not members of an injectable. The type-onlyreservations.bridgeimport repointed toreservations.servicedirectly (the bridge only re-exportsEndpointInput), so the relocation removed one ofreservations.bridge.ts's remaining import sites without touching its runtime exports. One risk the graph had not tracked: the legacy module had no direct test suite — its 12 superRefine error strings and the endpoint/metadata builder were pinned only through the 9 MCP transit tool cases — so the relocation added a 21-caseTRANSIT-ITIN-*characterization suite (the file now sits inside the ≥80%src/nest/**coverage gate). Still open, now tracked here: the client'sTransitSearchPanel.tsxhand-duplicates theendpoints/metadata.transitbuild with observable divergences frombuildTransitReservationParts(the scheduled-time fallback one is a real defect, fixed in the trailing commits below); the durable cure is lifting the contract toshared/src/, which remains follow-up work, not part of the relocation. -
(2026-08-01 regeneration, post collections fold) The frontier row's predictions held almost exactly: the legacy deps really were just
placeImage+permissions.bridge+ the lazy notificationService send, and the "bridge tax:mcp/tools/collections.ts" claim resolved to zero bridges because the whole 25-tool registrar moved onto the decorator registry rather than being repointed — the fifth consecutive zero-bridge fold, which by now looks like the rule rather than the exception (the graph's "bridge tax" column consistently over-predicts: a registrar is a port, not a repoint). The row's suggestion that the fold could share the dedup helpers withplaces.helpers.tswas NOT taken — the collection-scoped ports differ in table and semantics (name-first dedup with coords as fallback, vs. places' variants), so they stayed private to the service; only the never-calledbuildDedupSetmodule helper was dropped. Two things the graph had not tracked: (a) like transit-itinerary before it, the domain's MCP surface had no tests at all — the 25 tools were pinned only through the service suite — so the fold added a 23-casetools-collectionscharacterization suite; and (b) the legacy registrar registered without an addon gate while REST and the plugin host both gate on the collections addon — a real behavioral asymmetry that a naive "addwhen:like todo" port would have silently fixed; parity kept it and the new suite pins it (fixed in the trailing quirk commit below). -
(2026-08-01 regeneration, post atlas fold) The frontier row's "bridge lives long" warning held — the zero-bridge streak ends at five, exactly where the row predicted, because
authServiceis a legacy dependent, not a registrar: a registrar is a port, but a legacy importer really is a repoint, andatlas.bridge.ts(2 exports:getCountryFromCoordsre-exported from the pure helper,getHiddenCountriesover a bridge-localAtlasService) now waits for the authService fold to die. The "bridge tax:mcp/tools/atlas.ts,mcp/resources.ts" half over-predicted as usual — both were ports, and the resources port had a knock-on the graph had not tracked:resources.test.ts's harness runswithTools: false, which never attaches the decorator registry, so the moved resource cases had to migrate intotools-atlas-expanded.test.ts(vacay-harness shape) and the suite retired with its last two cases. Two more shapes the graph should keep tracking: (a) this was the second places-style split fold — the pure half went to a plain module (atlas-geo.ts) rather than the injectable, forced here by cache lifetime (twoAtlasServiceinstances exist in prod — container + bridge — and instance state would duplicate the multi-MB #1576 indexes and split the shared Nominatim throttle); and (b) unlike collections, the legacy MCP surface DID gate on the addon, so thewhen:gate is parity, not a trailing fix — the gate-asymmetry check is now a per-domain question, not a default. -
(2026-08-01 regeneration, post auth fold) The frontier row's bridge tax held exactly — adminService/oidcService/passkeyService plus the four
mcp/consumers, all import-path-only repoints — and the "18 in-container.mcp.tsconsumers" count resolved to 16 files (15 injections + the atlas exception; the row had countedoidc.service.tsand the passkey guard among them). Three shapes the graph had not tracked: (a) the fold created the reverse of the place fold's cycle — AuthService must inject AtlasService forgetTravelStats, soatlas.mcp.tscannot inject AuthService and stays onauth.bridge(the cycle-break-bridge category now has two members, one per direction); (b) the in-container fan-out was the real cost — 15 modules gained anAuthModuleimport andtests/helpers/mcp-test-controllers.tsthreads one sharedAuthServiceinto 15 constructions — a class of diff no bridge column predicts; and (c) two suites held silently-dead path mocks:oidc.e2e.test.tsmockedservices/authServicewhich OidcService no longer imports (fixed with an instance spy), and the legacy unit suite'svi.mock('src/mcp')never intercepted anything (authService always importedmcp/sessionManagerdirectly) — path-mock rot is now a named risk for every remaining fold. Also borne out: moving ~1400 lines under thesrc/nest/**gate dropped its branch coverage below 80% until the fold added AUTH-DB-050…088 — the gate polices relocations, not just new code.
Quirks preserved in the auth fold (trailing fix(server) candidates)
The relocation was byte-identical; these verified oddities were carried as-is and await their own fix commit (atlas/collections precedent — parity first, fixes separate):
getTravelStatsdrops 0-coordinates.if (p.lat && p.lng)skips a place on the equator or prime meridian (the exact|| nullclass the atlas trailing commit fixed in bucket items).registerUsermulti-writes aren't transactional. INSERT user → UPDATE invite_tokens →joinTripAsMemberrun statement-by-statement; a mid-sequence throw leaves a user without the invite bookkeeping.verifyMfaLoginsplices the backup code outside a transaction. The backup-code UPDATE and the last_login UPDATE are separate statements.enableMfabypassesgetPendingMfaSecret's TTL cleanup on failure — a wrong code leaves the pending secret live for the full 15 min (arguably by design).- The two "Password authentication is disabled" strings differ between login
(
… Please sign in with SSO.) and changePassword (bare) — parity says both stay. deleteMcpTokencallsrevokeUserSessionsunguarded while changePassword/ resetPassword wrap the same call in try/catch — an inconsistency, not yet a defect.updateApiKeysre-selects withcurrent!non-null assertions — a deleted-user race throws instead of 404ing (pre-existing).
Quirks fixed after the atlas fold (the trailing fix(server) commit)
The relocation itself was byte-identical; these were then fixed on top, each with a
regression test (ATLAS-SVC-031…036 + two MCP casing cases), so the parity diff and the
behaviour change stayed in separate commits:
- No transactions anywhere.
markCountry(2 statements),unmarkCountry(3),markRegion(4) andunmarkRegion(delete + tombstone + the country cascade, which nestsunmarkCountryas a savepoint) all ran statement-by-statement. All now run indb.transaction()("Transactions are not optional", server/CLAUDE.md) — ATLAS-SVC-035/036 pin the rollback. - A trip-less user's manually marked country read as unmarked.
countryPlaces' zero-trip early return hardcodedmanually_marked: false, skipping thevisited_countrieslookup entirely. The early return now performs the lookup (shape unchanged — ATLAS-SVC-031). updateBucketItemdropped explicit falsy values. Thex || nullbindings wrote NULL forlat: 0/lng: 0(a bucket item on the equator or prime meridian lost its pin) and collapsednotes: ''to NULL. Now?? null(ATLAS-SVC-032/033).- The mutating bucket SQL wasn't user-scoped. The UPDATE/DELETE (and the update's
re-select) ran on
idalone, guarded only by the preceding ownership SELECT. All three now carryAND user_id = ?— defense-in-depth, the ownership SELECT stays the primary gate (ATLAS-SVC-034). - The MCP region/country-places tools didn't uppercase.
mark_region_visitedstoredjp-13/jpverbatim — creating rows REST's uppercased unmark could never hit — andget_country_atlas_placespassedfrthrough to a lookup that matched nothing. Both now uppercase like the REST controller (andunmark_region_visitedwith them).
Quirks deliberately preserved (parity, not oversights): the detached GET-path
INSERT OR REPLACE INTO place_regions backfill writes in stats/visitedRegions
(lazy geocode cache-fill by design, deduped through the shared geocodingInFlight
set); getStats' two divergent return shapes (the zero-trip branch has trips: []
and only the four base stats — a client-visible contract); the import-time unref'd
cache-cleanup setInterval in atlas-geo.ts (documented parity exception,
audit-log.logger class); getCountryFromAddress returning bare 2-letter uppercase
address segments unvalidated (the heuristic the resolution order is built around);
and updateBucketItem's whitespace-only-name silent no-op (?.trim() || null +
COALESCE — shared with the MCP path, and the REST controller's trim guard 400s the
whitespace case before it ever reaches the service).
Quirks fixed after the collections fold (the trailing fix(server) commit)
The relocation itself was byte-identical; these were then fixed on top, each with a regression test, so the parity diff and the behaviour change stayed in separate commits:
- No transactions anywhere. Every multi-statement write (savePlace + tags +
ratings-copy, saveFromTripPlaces, updatePlace's field/tag/label rewrite,
deletePlacesMany, copyToTrip's places/tags/ratings inserts, assignLabels,
reorderCollections) ran statement-by-statement. All now run in
db.transaction()("Transactions are not optional", server/CLAUDE.md). - Bulk ops could partially commit.
deletePlacesManyinterleavedassertCanDeletewith the deletes andassignLabelschecked each list inside its write loop — a mid-list 403/404 left earlier work committed. Both now permission-check every id/list up front and write all-or-nothing (COLLECTIONS-SVC-090/091). - The MCP tools ignored the collections addon. The legacy registrar registered its
25 tools unconditionally while REST (
CollectionsAddonGuard) and the plugin host (requireAddon) refuse when the addon is off — and the addon is seeded disabled by default, so a fresh install exposed the whole MCP surface for a feature the admin never turned on. Every entry now rideswhen: collectionsAddonOn(todo pattern); the characterization case flipped from pinning the absence to pinning the gate. - The from-trip saves echoed to their origin client. The client sends
X-Socket-Idon every request, butPOST /places/from-trip(-many) never read the header andsaveFromTripPlaceshardcodedsocketId: undefined, so the saving client re-received its owncollections:updated. Both routes now forward the header (COLLECTIONS-SVC-092). - Two MCP handlers let unexpected throws escape.
invite_to_collectionandaccept_collection_invitewere the only handlers without try/catch — a db-level throw surfaced as an SDK protocol error instead ofisErrortext. Both now catch.
Quirks deliberately preserved (parity, not oversights): setRating gates on
assertAccess, not assertCanEdit — viewers cast personal votes by design (#1435);
sendInvite's notification send stays fire-and-forget with its double .catch(() => {});
roleOf collapses unknown member roles to 'editor' (defensive default);
updateCollection/updateLabel still broadcast even when no field changed (a harmless
refresh); updateLabel sets no updated_at because collection_labels has no such
column (not a defect); reorderCollections and copyToTrip still broadcast nothing;
findMembership still refuses to match on a bare name (deliberate, anti-false-positive);
and the dead findMembershipForUser export stays (now tested, zero consumers).
Quirks fixed after the place fold (the trailing fix(server) commit)
The relocation itself was byte-identical; these four defects were then fixed on top, each with a failing-first regression test, so the parity diff and the behaviour change stayed in separate commits:
- Coordinates of exactly 0 were dropped.
createPlaceran every optional field throughx || null, so a place on the equator or the prime meridian lost itslat/lng. Now?? nullfor lat/lng/price and?? 60for duration_minutes. duration_minutes: 0was unsettable. On create the falsy coercion replaced it with the 60-minute default; on updateduration_minutes || nullfedCOALESCE(?, …), which read the 0 as "absent" and silently kept the old value.- The journey delete hook fired on unscoped ids.
onPlaceDeletedkeys on the place id alone, soDELETE /:id,POST /bulk-deleteand thedelete_placeMCP tool could detach another trip's journey entries for an id they then refused. All three now scope first (via the newPlacesService.scopedIds, mirroring the guardPluginHostDepsFactoryalready had), andbulk_delete_placesalso moved its hook ahead of the DELETE —journey_entries.source_place_idis ON DELETE SET NULL, so running it afterwards left the entries as orphans instead of removing them. - The place search treated
%and_as wildcards. A bare%in the search box returned the whole trip. The three LIKE clauses now carryESCAPE '\'with the term escaped byescapeLikePattern.
Plus one hardening item on the same commit: the Google list-import response is now capped at
8 MB (declared content-length and post-read length, the transit.service precedent) and
its JSON.parse is guarded, so a malformed provider payload produces the existing
'Invalid list data received from Google Maps' 400 instead of throwing.
Quirks deliberately preserved (parity, not oversights): the non-COALESCE route_color
(an explicit null is how the picker resets a track to its category colour, #776);
currency/transport_mode still unclearable through x || null + COALESCE, since no client
action expresses "clear this string" and dropping COALESCE would change the absent-means-
unchanged contract; importGpx returning null (→ a 400) when a file parses but yields no
usable geometry; update skipping the If-Match check entirely when the stored updated_at
is null (old rows keep last-write-wins); and every remaining string-valued x || null, where
empty-string-means-absent is the intended reading.