Skip to content

defineLink() returns object with empty-string properties until bootstrap — silent runtime failure #14843

@dukedorje

Description

@dukedorje

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 output

The 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

  1. No error at import time — empty string is truthy-ish enough to not trigger null checks
  2. No TypeScript warning — the return type says entryPoint: string, which is technically correct
  3. Runtime error is misleadingService "undefined" was not found doesn't point to defineLink at all
  4. 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/utilsdefineLink() in modules-sdk/define-link.ts

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions