-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Description
Bug Report
Description
defineLink() returns an object with serviceName, entryPoint, and entity properties initialized to empty strings (""). These properties are only populated later when the register callback runs during MedusaModule.bootstrapAll().
This creates a subtle, silent bug: if you capture the primitive value of .entryPoint at module load time (which is the natural pattern when composing middleware or building configuration), you get an empty string with no error — until query.graph fails at runtime with Service "undefined" was not found.
Steps to Reproduce
// src/links/store-region.ts
import { defineLink } from "@medusajs/framework/utils"
import StoreModule from "@medusajs/medusa/store"
import RegionModule from "@medusajs/medusa/region"
export default defineLink(
{ linkable: StoreModule.linkable.store, isList: true },
{ linkable: RegionModule.linkable.region, isList: true }
)// src/api/middleware/scoping.ts
import StoreRegionLink from "../../links/store-region"
// BUG: StoreRegionLink.entryPoint is "" at this point.
// The string primitive is captured, not the object reference.
// It will NEVER update, even after bootstrap populates the object.
const entryPoint = StoreRegionLink.entryPoint // "" forever
function myMiddleware(req, res, next) {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
// Fails at runtime: Service "undefined" was not found
const { data } = await query.graph({
entity: entryPoint, // still ""
// ...
})
}Root Cause
In define-link.js (lines 102-107, 203-205, 296):
// Line 102-107: output created with empty strings
const output = {
[DefineLinkSymbol]: true,
serviceName: "",
entity: "",
entryPoint: "",
}
// Line 108-294: register function that populates output later
const register = function (modules) {
// ... module resolution ...
output.serviceName = composeLinkName(...) // Line 203
output.entryPoint = aliasA + "_" + aliasB // Line 204
output.entity = toPascalCase(...) // Line 205
// ...
}
// Line 295: register is deferred to bootstrap
global.MedusaModule.setCustomLink(register)
// Line 296: returns object with empty strings
return outputThe register callback mutates output in place during bootstrap, so if you hold the object reference and access .entryPoint at runtime (after bootstrap), it works. But if you capture the primitive string value at import time — which is the natural pattern when passing values to a factory function — you get "" silently.
Why This Is Hard to Debug
- No error at import time — empty string is truthy-ish enough to not trigger null checks
- No TypeScript warning — the return type says
entryPoint: string, which is technically correct - Runtime error is misleading —
Service "undefined" was not founddoesn't point todefineLinkat all - Works in some patterns, not others — accessing via object reference works; capturing the primitive doesn't
Suggested Fix
Any of these would prevent the silent failure:
Option A: Use property getters that throw before registration
const output = {
[DefineLinkSymbol]: true,
_serviceName: "",
_entity: "",
_entryPoint: "",
_registered: false,
get serviceName() {
if (!this._registered) throw new Error("Link not yet registered. Access .serviceName after app bootstrap, not at import time.")
return this._serviceName
},
get entryPoint() {
if (!this._registered) throw new Error("Link not yet registered. Access .entryPoint after app bootstrap, not at import time.")
return this._entryPoint
},
get entity() {
if (!this._registered) throw new Error("Link not yet registered. Access .entity after app bootstrap, not at import time.")
return this._entity
},
}Then in the register callback, set output._registered = true after populating values.
Option B: Document the lazy initialization
At minimum, add a JSDoc warning:
/**
* @returns An object whose `serviceName`, `entryPoint`, and `entity` properties
* are empty strings until the app bootstraps. Do NOT capture these as primitive
* values at import time. Instead, access them from the object reference at runtime.
*/
export function defineLink(...)Workaround
Pass the link object and resolve .entryPoint lazily at call time instead of at definition time:
// BEFORE (broken): captures empty string at module load
const middleware = createScopingMiddleware(StoreRegionLink.entryPoint, "region_id")
// AFTER (works): defers .entryPoint access to runtime
const middleware = createScopingMiddleware(StoreRegionLink, "region_id")
function createScopingMiddleware(
link: { entryPoint: string } | string,
field: string
) {
return async function (req, res, next) {
const entryPoint = typeof link === "string" ? link : link.entryPoint
// entryPoint is now correctly populated at runtime
// ...
}
}Environment
- Medusa version: 2.13.1
- Node.js: v24.6.0
- Package:
@medusajs/utils—defineLink()inmodules-sdk/define-link.ts