Skip to content

Reconciler Module Links & Migrations

Renaming a linked module or model orphans existing link rows

You renamed a custom module, say blog to article, or renamed a linked data model, and ran a migration like you always do. Now the products that used to carry a linked blog post do not show one anymore, and somewhere in Postgres a table full of real rows is just sitting there, unreachable. Here is why Medusa's Module Links system does this on a rename, and a small script that finds exactly which link tables were left behind and reports what to do about them.

Python and Node.js Medusa Admin API and Postgres Safe by default (report first)
Papers with tabs
Photo by Tanja Tepavac on Unsplash
The short answer

Medusa v2's Module Links system derives a link table's name deterministically from the linked modules' and data models' table names, for example product_product_blog_post. When you rename a custom module, such as blog to article, or rename a linked data model, defineLink produces a new, differently named link definition. Medusa has no way to know this is a rename rather than delete the old link and add a new one. Running npx medusa db:sync-links, or db:migrate, which calls it internally, then prompts to drop the old link table and create an empty new one, silently orphaning every existing row unless you pass a third defineLink config argument with database: { table: "<old_table_name>" } to pin the table name across the rename. Run a small Python or Node.js script that compares the link tables Medusa currently generates against what is still in Postgres, flags any table with rows that no longer matches a live link definition, and reports it. Full code, tests, and citations are below.

The problem in plain words

A Medusa module link is not stored inside either module. It lives in its own pivot table in Postgres, and Medusa names that table for you by combining the two sides of the link, their module keys and their table names. You never type the table name yourself, which is convenient until you rename one of the things the name was built from.

Rename the module, or rename the data model that a link points at, and the very next time defineLink runs, it computes a different table name from the new names. Medusa sees this as a brand new link that happens to look similar to one that used to exist, not as the same link under a new name. It has no rename detection, because there is nothing in defineLink's output that says "this used to be called something else."

Module renamed blog to article defineLink recomputes a new table name not seen as a rename Old table dropped db:sync-links prompt Rows orphaned
Medusa cannot tell a rename from a delete-and-add. A new link definition means a new table, and the old table with all its rows is left behind.

Why it happens

The mechanism is deterministic naming, not a bug in the migration runner. A few common ways teams end up here:

This is a common source of confusion because nothing in the application code looks wrong. The rename compiles, the workflow that reads the link still runs, it just quietly returns nothing for every product that used to resolve a linked post, because the rows describing that relationship are gone along with the table. See the citations at the end for the exact docs and threads.

The key insight

A link table cannot tell you on its own that it used to be called something else, because nothing about a Postgres table records the name it had before a rename. The only way to know is to compare what defineLink currently generates against what actually exists in Postgres, and check whether any table that no longer matches a current definition still holds rows. That comparison is a pure decision function, not a Medusa bug to patch, so the fix is a reconciliation script that reports first and never runs the destructive rename or restore step without a human turning that on explicitly.

The fix, as a flow

We do not touch the live link definitions. We add a job that enumerates the link tables Medusa currently generates from src/links/*.ts, lists what tables actually exist in Postgres, checks row counts on anything that does not match, and classifies each leftover table as orphaned or not. A human reviews the report and either patches the defineLink call with the old table name, or restores from backup if the drop already ran.

Enumerate defineLink src/links/*.ts List DB tables and counts information_schema, row counts Classify each table pure decision function Not defined and rows > 0? yes no, leave alone Skip, fine table is current Report under DRY_RUN, --apply for bridge
Only a table confirmed as orphaned by the pure classifier gets flagged, and only an explicit --apply flag lets the script attempt the ALTER TABLE RENAME bridge before rerunning db:sync-links.

Build it step by step

1

Get an Admin API session

Medusa Admin auth is a JWT, not a long-lived static token. Exchange an admin email and password for a token at POST /auth/user/emailpass, then send it back as Authorization: Bearer <token> on every /admin/* call. We use this to confirm how many entities on each side of a link currently resolve through the live link definition. Keep the backend URL, email, and password in environment variables.

setup (shell)
pip install requests

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   # start safe, report only
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true"   // start safe, report only
2

Enumerate the link tables Medusa currently generates

This part cannot come from an external script over HTTP, since the defined link table names live in your Medusa project's src/links/*.ts source. Run npx medusa db:migrate --dry-run to see, without writing anything, which link tables Medusa would create or expect for the current defineLink calls, and note the exact table names it prints.

shell (run inside the Medusa project)
# This step runs in your Medusa project directory, not in the external
# Python or Node.js script below. It never writes to the database.
#
#   npx medusa db:migrate --dry-run
#
# Read the output for the link table names Medusa currently expects from
# src/links/*.ts, for example product_product_article_post. Save that list
# to a file, defined_links.json, so the script in step 3 can read it.
shell (run inside the Medusa project)
// This step runs in your Medusa project directory, not in the external
// Python or Node.js script below. It never writes to the database.
//
//   npx medusa db:migrate --dry-run
//
// Read the output for the link table names Medusa currently expects from
// src/links/*.ts, for example product_product_article_post. Save that list
// to a file, defined_links.json, so the script in step 3 can read it.
3

List existing database tables and their row counts

Query information_schema.tables for every table name that matches the link naming pattern, such as containing an old module or model slug, and run SELECT count(*) on each candidate. This is the part that tells you whether a table Medusa no longer generates is still sitting there with real data in it.

step3.py
import json, os, requests

BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]

def login():
    r = requests.post(
        f"{BASE}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]

def load_defined_link_tables(path="defined_links.json"):
    # Produced in step 2 from `npx medusa db:migrate --dry-run` output.
    with open(path) as f:
        return json.load(f)

def load_db_table_report(token):
    # Exposed by a companion admin route or a medusa exec script that ran
    # information_schema.tables plus a count(*) per candidate table.
    # Expected shape: {"table_name": row_count, ...}
    r = requests.get(
        f"{BASE}/admin/link-table-report",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["row_counts"]
step3.js
import { readFile } from "node:fs/promises";

const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";

async function login() {
  const res = await fetch(`${BASE}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function loadDefinedLinkTables(path = "defined_links.json") {
  // Produced in step 2 from `npx medusa db:migrate --dry-run` output.
  return JSON.parse(await readFile(path, "utf8"));
}

async function loadDbTableReport(token) {
  // Exposed by a companion admin route or a medusa exec script that ran
  // information_schema.tables plus a count(*) per candidate table.
  // Expected shape: { "table_name": rowCount, ... }
  const res = await fetch(`${BASE}/admin/link-table-report`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.row_counts;
}
4

Classify every leftover table with one pure function

The decision belongs in its own function that takes the defined link table names, the tables that actually exist, and their row counts, since all the I/O already happened. A table that Medusa no longer generates and that still has rows is the dangerous case. We also try to guess which current link it used to be, by comparing shared name segments split on underscore, so the report points a human at the right defineLink call to patch.

decide.py
def _shared_segments(a, b):
    return set(a.split("_")) & set(b.split("_"))

def classify_link_rename(defined_link_tables, existing_db_tables, row_counts):
    results = []
    for table in existing_db_tables:
        if table in defined_link_tables:
            continue
        if row_counts.get(table, 0) <= 0:
            continue
        suspected = None
        best_overlap = 0
        for candidate in defined_link_tables:
            overlap = len(_shared_segments(table, candidate))
            if overlap > best_overlap:
                best_overlap = overlap
                suspected = candidate
        results.append({
            "orphaned_table": table,
            "row_count": row_counts[table],
            "suspected_rename_of": suspected,
        })
    return results
decide.js
function sharedSegments(a, b) {
  const setA = new Set(a.split("_"));
  const setB = new Set(b.split("_"));
  return [...setA].filter((seg) => setB.has(seg));
}

export function classifyLinkRename(input) {
  const { definedLinkTables, existingDbTables, rowCounts } = input;
  const results = [];
  for (const table of existingDbTables) {
    if (definedLinkTables.includes(table)) continue;
    if (!(rowCounts[table] > 0)) continue;
    let suspected = null;
    let bestOverlap = 0;
    for (const candidate of definedLinkTables) {
      const overlap = sharedSegments(table, candidate).length;
      if (overlap > bestOverlap) {
        bestOverlap = overlap;
        suspected = candidate;
      }
    }
    results.push({ orphanedTable: table, rowCount: rowCounts[table], suspectedRenameOf: suspected });
  }
  return results;
}
5

Report, then patch or restore, never auto-fix by default

By default the script only logs each orphaned table's name, row count, and suspected rename target. If the link was renamed but not yet migrated away, the fix is to add the third defineLink config argument with the old table name before the next db:sync-links run. If the drop already happened, the correct action is a DBA-reviewed ALTER TABLE ... RENAME TO bridge followed by db:sync-links, or restoring from a pre-migration backup, since Medusa's drop performs no soft delete.

repair (TypeScript, for reference)
# Both the config patch and the ALTER TABLE bridge are reviewed by hand,
# not run automatically by the external script below.
#
#   export const articlePostLink = defineLink(
#     { linkable: ProductModule.linkable.product },
#     { linkable: BlogModule.linkable.post },
#     { database: { table: "product_product_blog_post" } },
#   )
#
# If the drop already happened and the old table survives under a
# different name, the DBA-reviewed bridge is:
#
#   ALTER TABLE "product_product_blog_post"
#     RENAME TO "product_product_article_post";
#   -- then: npx medusa db:sync-links
#
# Only run this bridge with an explicit --apply flag on the script, never
# by default, and only after confirming row counts and both entity types
# match the report from step 4.
repair (TypeScript, for reference)
// Both the config patch and the ALTER TABLE bridge are reviewed by hand,
// not run automatically by the external script below.
//
//   export const articlePostLink = defineLink(
//     { linkable: ProductModule.linkable.product },
//     { linkable: BlogModule.linkable.post },
//     { database: { table: "product_product_blog_post" } }
//   );
//
// If the drop already happened and the old table survives under a
// different name, the DBA-reviewed bridge is:
//
//   ALTER TABLE "product_product_blog_post"
//     RENAME TO "product_product_article_post";
//   -- then: npx medusa db:sync-links
//
// Only run this bridge with an explicit --apply flag on the script, never
// by default, and only after confirming row counts and both entity types
// match the report from step 4.
Run it safe

Leave DRY_RUN=true so the script only reports orphaned tables. The ALTER TABLE ... RENAME TO bridge and the restore-from-backup path are both destructive or irreversible in different ways, so neither runs without an explicit --apply flag, and both should be DBA-reviewed against the exact table names and row counts in the report first.

The full code

Here is the complete script in one file for each language. It logs in, loads the link tables Medusa currently generates and the raw table and row-count report a companion step exposed, classifies every leftover table with the pure decision function, and reports every table that looks orphaned. The rename bridge itself stays documented rather than executed here, since it is a DBA-reviewed, schema-changing operation.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 88 Medusa fixes, free and open source.
classify_link_rename.py
"""Detect Medusa link table rows orphaned by a module or model rename.

Medusa v2's Module Links system derives a link table's name deterministically
from the linked modules' and data models' table names, for example
product_product_blog_post. When a developer renames a custom module (such as
blog to article) or a linked data model, defineLink produces a new,
differently named link definition. Medusa has no way to know this is a rename
rather than delete the old link and add a new one. Running
`npx medusa db:sync-links`, or `db:migrate`, which calls it internally, then
prompts to drop the old link table and create an empty new one, silently
orphaning every existing row unless the developer passes a third defineLink
config argument with database: { table: "" } to pin the table
name across the rename.

This script reads the link tables Medusa currently generates (captured from
`npx medusa db:migrate --dry-run` into defined_links.json) and a table and
row-count report a companion step exposed over the Admin API, classifies every
leftover table with a pure function, and reports every table that looks
orphaned along with its likely rename source. It only reports by default. The
ALTER TABLE RENAME TO bridge and the config patch are documented in the guide,
reviewed by a human, and only ever run with an explicit --apply flag.
"""
import json
import logging
import os
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("classify_link_rename")

BASE = os.environ["MEDUSA_BACKEND_URL"]
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def login():
    r = requests.post(
        f"{BASE}/auth/user/emailpass",
        json={"email": EMAIL, "password": PASSWORD},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["token"]


def load_defined_link_tables(path="defined_links.json"):
    # Produced from `npx medusa db:migrate --dry-run` output, run inside the
    # Medusa project. Expected shape: ["product_product_article_post", ...]
    with open(path) as f:
        return json.load(f)


def load_db_table_report(token):
    # Exposed by a companion admin route or a medusa exec script that queried
    # information_schema.tables plus a count(*) per candidate table.
    # Expected shape: {"table_name": row_count, ...}
    r = requests.get(
        f"{BASE}/admin/link-table-report",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["row_counts"]


def _shared_segments(a, b):
    return set(a.split("_")) & set(b.split("_"))


def classify_link_rename(defined_link_tables, existing_db_tables, row_counts):
    """Pure decision logic, no I/O.

    For each table in existing_db_tables not present in defined_link_tables
    (a link table Medusa no longer generates from current defineLink calls),
    mark it orphaned if it has rows. Use shared name segments split on "_" to
    guess which current link it was likely renamed from.
    """
    results = []
    for table in existing_db_tables:
        if table in defined_link_tables:
            continue
        if row_counts.get(table, 0) <= 0:
            continue
        suspected = None
        best_overlap = 0
        for candidate in defined_link_tables:
            overlap = len(_shared_segments(table, candidate))
            if overlap > best_overlap:
                best_overlap = overlap
                suspected = candidate
        results.append({
            "orphaned_table": table,
            "row_count": row_counts[table],
            "suspected_rename_of": suspected,
        })
    return results


def run():
    token = login()
    defined_link_tables = load_defined_link_tables()
    row_counts = load_db_table_report(token)
    existing_db_tables = list(row_counts.keys())

    orphans = classify_link_rename(defined_link_tables, existing_db_tables, row_counts)
    for orphan in orphans:
        log.warning(
            "Table %s has %d row(s), no longer defined. Suspected rename of: %s. %s",
            orphan["orphaned_table"], orphan["row_count"],
            orphan["suspected_rename_of"] or "unknown",
            "would report" if DRY_RUN else "confirmed, patch defineLink or restore from backup",
        )
    log.info("Done. %d orphaned link table(s) found.", len(orphans))


if __name__ == "__main__":
    run()
classify-link-rename.js
/**
 * Detect Medusa link table rows orphaned by a module or model rename.
 *
 * Medusa v2's Module Links system derives a link table's name deterministically
 * from the linked modules' and data models' table names, for example
 * product_product_blog_post. When a developer renames a custom module (such as
 * blog to article) or a linked data model, defineLink produces a new,
 * differently named link definition. Medusa has no way to know this is a
 * rename rather than delete the old link and add a new one. Running
 * `npx medusa db:sync-links`, or `db:migrate`, which calls it internally, then
 * prompts to drop the old link table and create an empty new one, silently
 * orphaning every existing row unless the developer passes a third defineLink
 * config argument with database: { table: "" } to pin the
 * table name across the rename.
 *
 * This script reads the link tables Medusa currently generates (captured from
 * `npx medusa db:migrate --dry-run` into defined_links.json) and a table and
 * row-count report a companion step exposed over the Admin API, classifies
 * every leftover table with a pure function, and reports every table that
 * looks orphaned along with its likely rename source. It only reports by
 * default. The ALTER TABLE RENAME TO bridge and the config patch are
 * documented in the guide, reviewed by a human, and only ever run with an
 * explicit --apply flag.
 *
 * Guide: https://www.allanninal.dev/medusa/link-table-orphaned-on-rename/
 */
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";

const BASE = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

function sharedSegments(a, b) {
  const setA = new Set(a.split("_"));
  const setB = new Set(b.split("_"));
  return [...setA].filter((seg) => setB.has(seg));
}

/**
 * Pure decision logic, no I/O.
 *
 * For each table in existingDbTables not present in definedLinkTables (a link
 * table Medusa no longer generates from current defineLink calls), mark it
 * orphaned if it has rows. Use shared name segments split on "_" to guess
 * which current link it was likely renamed from.
 */
export function classifyLinkRename(input) {
  const { definedLinkTables, existingDbTables, rowCounts } = input;
  const results = [];
  for (const table of existingDbTables) {
    if (definedLinkTables.includes(table)) continue;
    if (!(rowCounts[table] > 0)) continue;
    let suspected = null;
    let bestOverlap = 0;
    for (const candidate of definedLinkTables) {
      const overlap = sharedSegments(table, candidate).length;
      if (overlap > bestOverlap) {
        bestOverlap = overlap;
        suspected = candidate;
      }
    }
    results.push({ orphanedTable: table, rowCount: rowCounts[table], suspectedRenameOf: suspected });
  }
  return results;
}

async function login() {
  const res = await fetch(`${BASE}/auth/user/emailpass`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.token;
}

async function loadDefinedLinkTables(path = "defined_links.json") {
  // Produced from `npx medusa db:migrate --dry-run` output, run inside the
  // Medusa project. Expected shape: ["product_product_article_post", ...]
  return JSON.parse(await readFile(path, "utf8"));
}

async function loadDbTableReport(token) {
  // Exposed by a companion admin route or a medusa exec script that queried
  // information_schema.tables plus a count(*) per candidate table.
  // Expected shape: { "table_name": rowCount, ... }
  const res = await fetch(`${BASE}/admin/link-table-report`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error(`Medusa ${res.status}`);
  const body = await res.json();
  return body.row_counts;
}

export async function run() {
  const token = await login();
  const definedLinkTables = await loadDefinedLinkTables();
  const rowCounts = await loadDbTableReport(token);
  const existingDbTables = Object.keys(rowCounts);

  const orphans = classifyLinkRename({ definedLinkTables, existingDbTables, rowCounts });
  for (const orphan of orphans) {
    console.warn(
      `Table ${orphan.orphanedTable} has ${orphan.rowCount} row(s), no longer defined. ` +
      `Suspected rename of: ${orphan.suspectedRenameOf || "unknown"}. ` +
      `${DRY_RUN ? "would report" : "confirmed, patch defineLink or restore from backup"}`
    );
  }
  console.log(`Done. ${orphans.length} orphaned link table(s) found.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The classifier is the part most worth testing, because it decides which tables get reported as orphaned and which are safe to leave alone. Because classify_link_rename is pure, taking only precomputed table lists and row counts, the test needs no network, no Postgres connection, and no Medusa backend. It just feeds in fixture data and checks the classification.

test_link_table_orphaned_rename.py
from classify_link_rename import classify_link_rename


def test_no_orphans_when_all_tables_defined():
    result = classify_link_rename(
        ["product_product_article_post"],
        ["product_product_article_post"],
        {"product_product_article_post": 42},
    )
    assert result == []


def test_orphaned_when_table_undefined_and_has_rows():
    result = classify_link_rename(
        ["product_product_article_post"],
        ["product_product_article_post", "product_product_blog_post"],
        {"product_product_article_post": 10, "product_product_blog_post": 87},
    )
    assert len(result) == 1
    assert result[0]["orphaned_table"] == "product_product_blog_post"
    assert result[0]["row_count"] == 87


def test_not_orphaned_when_undefined_table_is_empty():
    result = classify_link_rename(
        ["product_product_article_post"],
        ["product_product_article_post", "product_product_blog_post"],
        {"product_product_article_post": 10, "product_product_blog_post": 0},
    )
    assert result == []


def test_suspected_rename_of_uses_shared_segments():
    result = classify_link_rename(
        ["product_product_article_post"],
        ["product_product_blog_post"],
        {"product_product_blog_post": 5},
    )
    assert result[0]["suspected_rename_of"] == "product_product_article_post"


def test_suspected_rename_of_is_none_with_no_overlap():
    result = classify_link_rename(
        ["sales_channel_stock_location"],
        ["product_product_blog_post"],
        {"product_product_blog_post": 5},
    )
    assert result[0]["suspected_rename_of"] is None


def test_multiple_orphans_reported_independently():
    result = classify_link_rename(
        ["product_product_article_post"],
        ["product_product_blog_post", "product_variant_old_inventory_item"],
        {"product_product_blog_post": 3, "product_variant_old_inventory_item": 9},
    )
    orphaned_names = {row["orphaned_table"] for row in result}
    assert orphaned_names == {"product_product_blog_post", "product_variant_old_inventory_item"}
classify-link-rename.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyLinkRename } from "./classify-link-rename.js";

test("no orphans when all tables defined", () => {
  const result = classifyLinkRename({
    definedLinkTables: ["product_product_article_post"],
    existingDbTables: ["product_product_article_post"],
    rowCounts: { product_product_article_post: 42 },
  });
  assert.deepEqual(result, []);
});

test("orphaned when table undefined and has rows", () => {
  const result = classifyLinkRename({
    definedLinkTables: ["product_product_article_post"],
    existingDbTables: ["product_product_article_post", "product_product_blog_post"],
    rowCounts: { product_product_article_post: 10, product_product_blog_post: 87 },
  });
  assert.equal(result.length, 1);
  assert.equal(result[0].orphanedTable, "product_product_blog_post");
  assert.equal(result[0].rowCount, 87);
});

test("not orphaned when undefined table is empty", () => {
  const result = classifyLinkRename({
    definedLinkTables: ["product_product_article_post"],
    existingDbTables: ["product_product_article_post", "product_product_blog_post"],
    rowCounts: { product_product_article_post: 10, product_product_blog_post: 0 },
  });
  assert.deepEqual(result, []);
});

test("suspected rename of uses shared segments", () => {
  const result = classifyLinkRename({
    definedLinkTables: ["product_product_article_post"],
    existingDbTables: ["product_product_blog_post"],
    rowCounts: { product_product_blog_post: 5 },
  });
  assert.equal(result[0].suspectedRenameOf, "product_product_article_post");
});

test("suspected rename of is null with no overlap", () => {
  const result = classifyLinkRename({
    definedLinkTables: ["sales_channel_stock_location"],
    existingDbTables: ["product_product_blog_post"],
    rowCounts: { product_product_blog_post: 5 },
  });
  assert.equal(result[0].suspectedRenameOf, null);
});

test("multiple orphans reported independently", () => {
  const result = classifyLinkRename({
    definedLinkTables: ["product_product_article_post"],
    existingDbTables: ["product_product_blog_post", "product_variant_old_inventory_item"],
    rowCounts: { product_product_blog_post: 3, product_variant_old_inventory_item: 9 },
  });
  const orphanedNames = new Set(result.map((row) => row.orphanedTable));
  assert.deepEqual(orphanedNames, new Set(["product_product_blog_post", "product_variant_old_inventory_item"]));
});

Case studies

Module rename

A content module rename left a whole link table behind

A team building a content feature on top of Medusa renamed their custom blog module to article mid-project, since the product name had changed. They ran npx medusa db:migrate as part of the usual deploy, confirmed the interactive prompt without reading it closely, and moved on. A week later, product pages that used to show a linked article stopped showing anything.

The reconciliation script compared the link tables Medusa currently generated against Postgres and found product_product_blog_post still sitting there with several thousand rows, no longer matched by any current defineLink call. The team restored the table from a pre-migration backup, added the third defineLink argument pinning the old table name, and reran db:sync-links cleanly.

Data model rename

Renaming a model inside an unchanged module still broke the link

A developer renamed a data model from Post to Article inside a module whose key never changed, assuming the module name was the only thing that mattered to Medusa's link naming. The next CI run auto-confirmed the db:sync-links prompt in a non-interactive pipeline, and the old link table was dropped with nobody watching.

Running the detector against a fresh Postgres snapshot showed the pattern clearly: a table with real row counts that no longer appeared in defineLink's current output, with a strong name-segment match to the new link. That match pointed the team straight at the exact defineLink call to patch before it happened again on the next rename.

What good looks like

After this runs before every migration that touches a renamed module or model, a rename that would have silently dropped a link table gets caught in a dry run report instead of discovered days later on a product page. Every fix stays a deliberate, reviewed step, either pinning the old table name in defineLink before the drop happens, or a DBA-reviewed rename bridge or backup restore after it already has, never an automatic rewrite of a schema Medusa's own migration tool just changed.

FAQ

Why does renaming a module or model orphan Medusa link table rows?

Medusa v2 derives a link table's name deterministically from the linked modules' and data models' table names, such as product_product_blog_post. When you rename a custom module or a linked data model, defineLink produces a new, differently named link definition. Medusa has no way to know this is a rename rather than delete the old link and add a new one, so db:sync-links or db:migrate prompts to drop the old table and create an empty new one.

How do I stop db:sync-links from dropping my renamed link table?

Pass a third argument to defineLink with database set to table and the exact old table name, before the next db:sync-links or db:migrate run. That pins the link table name across the rename so Medusa reuses the existing table instead of dropping it and creating an empty one.

What do I do if the old link table was already dropped?

There is no safe automatic fix once the drop has run, since Medusa performs no soft delete on a dropped table. Restore the table from a pre-migration backup, or manually rename the surviving table back to the name Medusa currently expects with ALTER TABLE RENAME TO and then rerun db:sync-links so Medusa reconciles it. Treat this as a DBA-reviewed operation, not something a script should do without an explicit apply flag.

Related field notes

Citations

On the problem:

  1. Define Module Link, Medusa Documentation, renaming participants in a module link. docs.medusajs.com/learn/fundamentals/module-links
  2. Issue #9134: Truncated module link identifiers causing unrecoverable database collisions. github.com/medusajs/medusa/issues/9134
  3. Link API medusa v2, Discussion #10108. github.com/medusajs/medusa/discussions/10108

On the solution:

  1. Define Module Link, Medusa Documentation, the defineLink third parameter and database.table config. docs.medusajs.com/learn/fundamentals/module-links
  2. Add Columns to a Link Table, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links/custom-columns
  3. Query, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links/query

Stuck on a tricky one?

If you have a problem in Medusa pricing, regions, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a link table rename for you?

If this caught a table that was about to get dropped, or explained a page that quietly stopped showing linked data, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Medusa.js field notes