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.
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."
Why it happens
The mechanism is deterministic naming, not a bug in the migration runner. A few common ways teams end up here:
- A custom module is renamed, for example
blogtoarticle, and everydefineLinkcall that referenced the old module key now resolves to a table likeproduct_product_article_postinstead of the originalproduct_product_blog_post. - A data model inside a module is renamed, for example a model called
PostbecomesArticle, changing the table name segment even though the module key itself did not change. - The developer runs
npx medusa db:migrateas part of a normal deploy, not realizing it callsdb:sync-linksinternally, and accepts the interactive prompt to drop the table that no longer matches any currentdefineLinkoutput. - A staging or CI pipeline runs migrations non-interactively with a flag that auto-confirms prompts, so the drop happens with nobody in the loop to notice the table name changed.
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.
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.
Build it step by step
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.
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
// 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
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.
# 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.
// 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.
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.
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"]
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;
}
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.
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
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;
}
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.
# 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.
// 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.
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.
"""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()
/**
* 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.
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"}
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
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.
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.
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:
- Define Module Link, Medusa Documentation, renaming participants in a module link. docs.medusajs.com/learn/fundamentals/module-links
- Issue #9134: Truncated module link identifiers causing unrecoverable database collisions. github.com/medusajs/medusa/issues/9134
- Link API medusa v2, Discussion #10108. github.com/medusajs/medusa/discussions/10108
On the solution:
- Define Module Link, Medusa Documentation, the defineLink third parameter and database.table config. docs.medusajs.com/learn/fundamentals/module-links
- Add Columns to a Link Table, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links/custom-columns
- 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.
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