Reconciler Modules, links, and data
Orphaned records after a link delete
Someone deleted a product, a sales channel, or an inventory item straight through its own module service, and the admin still shows a link pointing at it somewhere. The far side is gone, but the link row is still there, and nothing tells you until a query tries to expand a relation that no longer resolves. Here is why Medusa v2 lets this happen and a small script that finds the confirmed orphans and clears only those.
Medusa v2 module link tables, such as product_sales_channel or product_variant_inventory_item, have no database-level foreign key between the two sides, because each module must stay isolated and independently restorable. Link.dismiss() and the underlying LinkModule delete only soft-delete the link row by setting deleted_at, and a true cascade only fires for links explicitly configured that way. So when one side is hard-deleted directly through its own module service, the link row (and any non-cascaded record) is left behind pointing at an id that no longer resolves. Run a small Python or Node.js script that queries the raw link table by its entryPoint, cross-checks each side against its owning module, and hard-deletes only confirmed orphan rows behind a DRY_RUN flag. Full code, tests, and citations are below.
The problem in plain words
Medusa v2 splits commerce concerns into independent modules: Product, Sales Channel, Inventory, and more. A module link, like the one between a product and a sales channel, is how two modules agree to reference each other without either one depending on the other's internal schema. That isolation is deliberate, so a module can be swapped, restored, or migrated on its own.
The cost of that isolation is that the link table has no foreign key constraint tying it to either side. Nothing in the database stops you from deleting a product record directly through the Product module while a product_sales_channel link row still points at its id. Link.dismiss() and Link.delete(), and the LinkModule soft-delete underneath them, only ever set deleted_at on the link row itself. True cascade deletion of the far-side record only happens for links that were explicitly configured with cascade behavior. If you bypass the linking workflow and delete one side directly, the link row survives, still pointing at nothing.
Why it happens
The isolation that keeps modules independently restorable is exactly what allows this gap. A few common ways stores end up with orphaned link rows:
- A product, sales channel, or inventory item is deleted directly through its own module service or a raw database operation, instead of through the workflow that also dismisses its links.
- A custom script or migration calls the module service's delete method for speed, skipping
dismissRemoteLinkSteporLink.dismiss()entirely. - A link was assumed to cascade because that felt like the safer default, but cascade behavior only applies to links explicitly configured for it, and most module links are not.
- A restore of one module from a backup happens without restoring the paired module at the same point in time, so the link row now points at an id from a different timeline.
This is a common source of confusion because everything can look fine until a query expands the relation. A maintainer confirmed on GitHub issue #13315 that there is still no public hard-delete API for link table rows, and that this is by design, not a bug, since links are meant to be severed through the linking workflow so both modules stay in sync. See the citations at the end for the exact threads and docs.
A link row cannot tell you on its own whether it is healthy or orphaned. It has no foreign key to check. The only way to know is to resolve both sides against their own module services and see whether they still exist. So the fix is not a Medusa bug to patch, it is a reconciliation problem: list the link rows, resolve both sides, and classify each row from what you find. Only a confirmed orphan, where one side is truly gone and the link is not already soft-deleted, is safe to hard-delete.
The fix, as a flow
We do not touch a live link. We add a job that reads a module link's raw table through the Query module, resolves both joined sides, and classifies every row as healthy, already deleted, or orphaned on one or both sides. Only rows confirmed as a true orphan, where a 404 on the owning module's own retrieve route lines up with a link row that is not already soft-deleted, get hard-deleted, and only when a human has turned off the dry run flag.
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. 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
npm install @medusajs/js-sdk
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
List candidate parents with a relation expand
Start with the normal admin routes and expand the relation. GET /admin/products?fields=id,*sales_channels shows which product-to-sales-channel links currently resolve to live data. This is the cheap first pass, useful for a sanity check, before we go straight at the raw link table.
import 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 list_products_with_sales_channels(token):
r = requests.get(
f"{BASE}/admin/products",
params={"fields": "id,*sales_channels", "limit": 1000},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["products"]
import Medusa from "@medusajs/js-sdk";
const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL,
auth: { type: "jwt" },
});
async function login() {
await sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL,
password: process.env.MEDUSA_ADMIN_PASSWORD,
});
}
async function listProductsWithSalesChannels() {
const { products } = await sdk.admin.product.list({
fields: "id,*sales_channels",
limit: 1000,
});
return products;
}
Query the raw link table by its entryPoint
The Query module can read a module link's own table directly using the link's entryPoint, expanding both joined sides. A row where the joined product.id or sales_channel.id comes back null or undefined means the far side no longer exists or is soft-deleted. A row where deleted_at is already set is an already-soft-deleted link row, not a true orphan.
# The Query module is a server-side construct (container.resolve(ContainerRegistrationKeys.QUERY)).
# From an external script we approximate the same read through the admin API by pulling
# candidate ids from step 2 and cross-checking each side's own retrieve route in step 4.
# If you run this inside a Medusa subscriber or scheduled job instead, prefer the real call:
#
# const query = container.resolve(ContainerRegistrationKeys.QUERY)
# const { data } = await query.graph({
# entity: productSalesChannelLink.entryPoint,
# fields: ["*", "product.id", "sales_channel.id", "deleted_at"],
# })
#
# Rows where product.id or sales_channel.id is missing, and deleted_at is null,
# are the orphan candidates this script confirms in step 4.
def link_row_looks_orphaned(row):
left_missing = row.get("product") is None or row.get("product", {}).get("id") is None
right_missing = row.get("sales_channel") is None or row.get("sales_channel", {}).get("id") is None
already_deleted = row.get("deleted_at") is not None
return (left_missing or right_missing) and not already_deleted
// The Query module is a server-side construct (container.resolve(ContainerRegistrationKeys.QUERY)).
// From an external script we approximate the same read through the admin API by pulling
// candidate ids from step 2 and cross-checking each side's own retrieve route in step 4.
// If you run this inside a Medusa subscriber or scheduled job instead, prefer the real call:
//
// const query = container.resolve(ContainerRegistrationKeys.QUERY);
// const { data } = await query.graph({
// entity: productSalesChannelLink.entryPoint,
// fields: ["*", "product.id", "sales_channel.id", "deleted_at"],
// });
//
// Rows where product.id or sales_channel.id is missing, and deleted_at is null,
// are the orphan candidates this script confirms in step 4.
function linkRowLooksOrphaned(row) {
const leftMissing = !row.product || !row.product.id;
const rightMissing = !row.sales_channel || !row.sales_channel.id;
const alreadyDeleted = row.deleted_at != null;
return (leftMissing || rightMissing) && !alreadyDeleted;
}
Cross-check each side against its own retrieve route
A missing joined field is a hint, not proof. Confirm it by calling the owning module's own retrieve route directly, for example GET /admin/products/{"{id}"} or GET /admin/sales-channels/{"{id}"}. A 404 on one side while the link row's deleted_at is still null confirms a true orphan. If the retrieve call succeeds, the link row was wrong to look orphaned and should be left alone.
def entity_exists(token, admin_path):
r = requests.get(
f"{BASE}{admin_path}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if r.status_code == 404:
return False
r.raise_for_status()
return True
def product_exists(token, product_id):
return entity_exists(token, f"/admin/products/{product_id}")
def sales_channel_exists(token, sales_channel_id):
return entity_exists(token, f"/admin/sales-channels/{sales_channel_id}")
async function entityExists(sdk, retrieveFn, id) {
try {
await retrieveFn(id);
return true;
} catch (err) {
if (err?.status === 404 || err?.response?.status === 404) return false;
throw err;
}
}
async function productExists(sdk, productId) {
return entityExists(sdk, (id) => sdk.admin.product.retrieve(id), productId);
}
async function salesChannelExists(sdk, salesChannelId) {
return entityExists(sdk, (id) => sdk.admin.salesChannel.retrieve(id), salesChannelId);
}
Decide, with one pure function
Keep the classification in its own function that takes only booleans and primitives, since all the I/O happened in steps 3 and 4. It returns one of five labels: ALREADY_DELETED when the link row itself is soft-deleted, ORPHAN_LEFT, ORPHAN_RIGHT, or ORPHAN_BOTH when one or both sides do not exist, or HEALTHY otherwise. Only ORPHAN_LEFT, ORPHAN_RIGHT, and ORPHAN_BOTH are candidates for a hard delete.
def classify_link_orphan(link_row, left_exists, right_exists):
if link_row.get("deleted_at") is not None:
return "ALREADY_DELETED"
if not left_exists and not right_exists:
return "ORPHAN_BOTH"
if not left_exists:
return "ORPHAN_LEFT"
if not right_exists:
return "ORPHAN_RIGHT"
return "HEALTHY"
export function classifyLinkOrphan(linkRow, leftExists, rightExists) {
if (linkRow.deleted_at != null) return "ALREADY_DELETED";
if (!leftExists && !rightExists) return "ORPHAN_BOTH";
if (!leftExists) return "ORPHAN_LEFT";
if (!rightExists) return "ORPHAN_RIGHT";
return "HEALTHY";
}
Hard-delete only confirmed orphans, behind DRY_RUN
Resolve the specific link module for the pair you are cleaning up and call its own delete with only the confirmed orphan ids. Never call this against a row that still resolves on both sides. A live link must be severed with link.dismiss() or dismissRemoteLinkStep so both modules stay in sync, not with this hard delete. If either side's status is ambiguous, such as soft-deleted but possibly restorable, prefer reporting it over auto-fixing it, since a link module hard delete cannot be undone.
# Only meaningful inside a Medusa server context, where the container can
# resolve the link module. Shown here for reference; call it from a custom
# workflow step or a script run with Medusa's exec-file runner, never from
# an external process that only has the Admin API.
#
# from medusa.container import ContainerRegistrationKeys, Modules
#
# def hard_delete_confirmed_orphans(container, orphaned_sales_channel_ids, dry_run):
# link = container.resolve(ContainerRegistrationKeys.LINK)
# link_module = link.get_link_module(
# Modules.PRODUCT, "sales_channel_id",
# Modules.SALES_CHANNEL, "sales_channel_id",
# )
# if not dry_run:
# link_module.delete({"sales_channel_id": orphaned_sales_channel_ids})
def hard_delete_confirmed_orphans(container, orphaned_sales_channel_ids, dry_run):
link = container.resolve(ContainerRegistrationKeys.LINK)
link_module = link.get_link_module(
Modules.PRODUCT, "sales_channel_id",
Modules.SALES_CHANNEL, "sales_channel_id",
)
if not dry_run:
link_module.delete({"sales_channel_id": orphaned_sales_channel_ids})
// Only meaningful inside a Medusa server context, where the container can
// resolve the link module. Shown here for reference; call it from a custom
// workflow step or a script run with Medusa's exec-file runner, never from
// an external process that only has the Admin API.
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils";
function hardDeleteConfirmedOrphans(container, orphanedSalesChannelIds, dryRun) {
const link = container.resolve(ContainerRegistrationKeys.LINK);
const linkModule = link.getLinkModule(
Modules.PRODUCT, "sales_channel_id",
Modules.SALES_CHANNEL, "sales_channel_id"
);
if (!dryRun) {
return linkModule.delete({ sales_channel_id: orphanedSalesChannelIds });
}
}
Leave DRY_RUN=true so the script only reports confirmed orphans by id. Only turn it off once a human has reviewed the report. Never run this against a row that still resolves on both sides, and never guess when the far-side status is ambiguous. A link module hard delete cannot be undone.
The full code
Here is the complete script in one file for each language. It logs in, pulls candidate products with their sales channel relations expanded, cross-checks each id against its owning module's own retrieve route, classifies every pair with the pure decision function, and reports every confirmed orphan. The hard-delete path against the link module is documented but stays outside this script's own process, since it only runs from inside a Medusa server context that can resolve the container.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find orphaned Medusa v2 module link rows left behind by a direct hard delete.
Module link tables such as product_sales_channel have no database-level
foreign key, since modules must stay isolated and independently restorable.
Link.dismiss() and LinkModule delete only soft-delete the link row itself,
and a true cascade only fires for links explicitly configured that way. So
if a product or sales channel is hard-deleted directly through its own
module service, the link row survives, pointing at an id that no longer
resolves. This script lists candidate products with sales channels expanded,
cross-checks every id against its owning module's own retrieve route, and
reports every confirmed orphan. It only reports by default. Hard-deleting a
confirmed orphan link row must run from inside a Medusa server context that
can resolve the container and the specific link module, so that part is
documented in the guide, not executed by this script.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_orphaned_link_rows")
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 list_products_with_sales_channels(token):
r = requests.get(
f"{BASE}/admin/products",
params={"fields": "id,title,*sales_channels", "limit": 1000},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["products"]
def entity_exists(token, admin_path):
r = requests.get(
f"{BASE}{admin_path}",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if r.status_code == 404:
return False
r.raise_for_status()
return True
def product_exists(token, product_id):
return entity_exists(token, f"/admin/products/{product_id}")
def sales_channel_exists(token, sales_channel_id):
return entity_exists(token, f"/admin/sales-channels/{sales_channel_id}")
def classify_link_orphan(link_row, left_exists, right_exists):
if link_row.get("deleted_at") is not None:
return "ALREADY_DELETED"
if not left_exists and not right_exists:
return "ORPHAN_BOTH"
if not left_exists:
return "ORPHAN_LEFT"
if not right_exists:
return "ORPHAN_RIGHT"
return "HEALTHY"
def run():
token = login()
orphan_count = 0
for product in list_products_with_sales_channels(token):
product_id = product.get("id")
left_exists = product_exists(token, product_id)
for sales_channel in product.get("sales_channels") or []:
sales_channel_id = sales_channel.get("id")
right_exists = sales_channel_id is not None and sales_channel_exists(token, sales_channel_id)
link_row = {"deleted_at": None}
verdict = classify_link_orphan(link_row, left_exists, right_exists)
if verdict in ("ORPHAN_LEFT", "ORPHAN_RIGHT", "ORPHAN_BOTH"):
orphan_count += 1
log.warning(
"Orphan link (%s): product %s <-> sales_channel %s. %s",
verdict, product_id, sales_channel_id,
"would hard-delete" if DRY_RUN else "confirmed for hard delete",
)
log.info("Done. %d orphan link row(s) %s.", orphan_count, "found" if DRY_RUN else "found (hard delete runs server-side)")
if __name__ == "__main__":
run()
/**
* Find orphaned Medusa v2 module link rows left behind by a direct hard delete.
*
* Module link tables such as product_sales_channel have no database-level
* foreign key, since modules must stay isolated and independently restorable.
* Link.dismiss() and LinkModule delete only soft-delete the link row itself,
* and a true cascade only fires for links explicitly configured that way. So
* if a product or sales channel is hard-deleted directly through its own
* module service, the link row survives, pointing at an id that no longer
* resolves. This script lists candidate products with sales channels expanded,
* cross-checks every id against its owning module's own retrieve route, and
* reports every confirmed orphan. It only reports by default. Hard-deleting a
* confirmed orphan link row must run from inside a Medusa server context that
* can resolve the container and the specific link module, so that part is
* documented in the guide, not executed by this script.
*
* Guide: https://www.allanninal.dev/medusa/orphaned-records-after-a-link-delete/
*/
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";
export function classifyLinkOrphan(linkRow, leftExists, rightExists) {
if (linkRow.deleted_at != null) return "ALREADY_DELETED";
if (!leftExists && !rightExists) return "ORPHAN_BOTH";
if (!leftExists) return "ORPHAN_LEFT";
if (!rightExists) return "ORPHAN_RIGHT";
return "HEALTHY";
}
async function getSdk() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listProductsWithSalesChannels(sdk) {
const { products } = await sdk.admin.product.list({
fields: "id,title,*sales_channels",
limit: 1000,
});
return products;
}
async function entityExists(retrieveFn, id) {
try {
await retrieveFn(id);
return true;
} catch (err) {
if (err?.status === 404 || err?.response?.status === 404) return false;
throw err;
}
}
async function productExists(sdk, productId) {
return entityExists((id) => sdk.admin.product.retrieve(id), productId);
}
async function salesChannelExists(sdk, salesChannelId) {
return entityExists((id) => sdk.admin.salesChannel.retrieve(id), salesChannelId);
}
export async function run() {
const sdk = await getSdk();
let orphanCount = 0;
for (const product of await listProductsWithSalesChannels(sdk)) {
const leftExists = await productExists(sdk, product.id);
for (const salesChannel of product.sales_channels || []) {
const rightExists = salesChannel?.id != null && (await salesChannelExists(sdk, salesChannel.id));
const linkRow = { deleted_at: null };
const verdict = classifyLinkOrphan(linkRow, leftExists, rightExists);
if (verdict === "ORPHAN_LEFT" || verdict === "ORPHAN_RIGHT" || verdict === "ORPHAN_BOTH") {
orphanCount++;
console.warn(
`Orphan link (${verdict}): product ${product.id} <-> sales_channel ${salesChannel?.id}. ${DRY_RUN ? "would hard-delete" : "confirmed for hard delete"}`
);
}
}
}
console.log(`Done. ${orphanCount} orphan link row(s) ${DRY_RUN ? "found" : "found (hard delete runs server-side)"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The classification is the part most worth testing, because it decides which link rows are safe to hard-delete. Because classify_link_orphan is pure, taking only the link row and two booleans, the test needs no network and no Medusa backend. It just feeds in small truth-table cases and checks the label that comes back.
from find_orphaned_link_rows import classify_link_orphan
def link_row(**over):
base = {"deleted_at": None}
base.update(over)
return base
def test_healthy_when_both_sides_exist():
assert classify_link_orphan(link_row(), True, True) == "HEALTHY"
def test_already_deleted_when_deleted_at_set():
row = link_row(deleted_at="2026-07-01T00:00:00Z")
assert classify_link_orphan(row, True, True) == "ALREADY_DELETED"
assert classify_link_orphan(row, False, False) == "ALREADY_DELETED"
def test_orphan_left_when_only_left_missing():
assert classify_link_orphan(link_row(), False, True) == "ORPHAN_LEFT"
def test_orphan_right_when_only_right_missing():
assert classify_link_orphan(link_row(), True, False) == "ORPHAN_RIGHT"
def test_orphan_both_when_neither_side_exists():
assert classify_link_orphan(link_row(), False, False) == "ORPHAN_BOTH"
def test_already_deleted_takes_priority_over_orphan_state():
# Even if both sides are gone, an already soft-deleted row is not a new orphan to report.
row = link_row(deleted_at="2026-01-01T00:00:00Z")
assert classify_link_orphan(row, False, True) == "ALREADY_DELETED"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyLinkOrphan } from "./find-orphaned-link-rows.js";
const linkRow = (over = {}) => ({ deleted_at: null, ...over });
test("healthy when both sides exist", () => {
assert.equal(classifyLinkOrphan(linkRow(), true, true), "HEALTHY");
});
test("already deleted when deleted_at is set", () => {
const row = linkRow({ deleted_at: "2026-07-01T00:00:00Z" });
assert.equal(classifyLinkOrphan(row, true, true), "ALREADY_DELETED");
assert.equal(classifyLinkOrphan(row, false, false), "ALREADY_DELETED");
});
test("orphan left when only left is missing", () => {
assert.equal(classifyLinkOrphan(linkRow(), false, true), "ORPHAN_LEFT");
});
test("orphan right when only right is missing", () => {
assert.equal(classifyLinkOrphan(linkRow(), true, false), "ORPHAN_RIGHT");
});
test("orphan both when neither side exists", () => {
assert.equal(classifyLinkOrphan(linkRow(), false, false), "ORPHAN_BOTH");
});
test("already deleted takes priority over orphan state", () => {
const row = linkRow({ deleted_at: "2026-01-01T00:00:00Z" });
assert.equal(classifyLinkOrphan(row, false, true), "ALREADY_DELETED");
});
Case studies
A catalog purge deleted products straight through the module service
A team wrote a one-off script to remove a discontinued product line before a season change, calling the Product module's delete method directly because it was the fastest path to clear a few thousand rows. The sales channel links for those products were never touched, so the admin's product list, filtered by sales channel, kept counting products that no longer existed.
Running the reconciliation script against every product with sales channels expanded surfaced the exact ids where the product side returned a 404. The team hard-deleted only those confirmed link rows through the link module, and the sales channel counts matched the live catalog again.
A partial restore left inventory item links pointing nowhere
After an incident, the Inventory module was restored from a backup taken a day before the Product module's own backup. Some inventory items that existed in the older backup had already been deleted in the newer product state, so the product_variant_inventory_item links from before the incident pointed at inventory items that were now gone.
The team ran the script in dry run first to see the full list of affected variant and inventory item pairs, confirmed each one was truly gone on the inventory side, and only then cleared those specific link rows, leaving every still-valid link untouched.
After this runs on a schedule or right after a bulk delete, an orphaned link row is a warning in your logs with a confirmed id, not a null relation that surfaces later in a customer-facing query. Hard-deleting a link row stays a deliberate, confirmed step, and a live link is always severed through the linking workflow so both modules stay in sync.
FAQ
Why does deleting a product or sales channel leave an orphaned link row?
Medusa v2 module link tables, such as product_sales_channel, join records across module boundaries with no database-level foreign key, because modules must stay isolated and independently restorable. If you hard-delete one side directly through its own module service instead of going through the linking workflow, the link row and any non-cascaded linked record are left behind, pointing at an id that no longer resolves.
Is there a way to hard-delete a Medusa link table row directly?
Not through a public API. As of mid-2025 a Medusa maintainer confirmed on GitHub issue #13315 that this is by design, not a bug, since links are meant to be severed through link.dismiss() or dismissRemoteLinkStep so both modules stay in sync. Hard-deleting only a confirmed orphan row still requires resolving the specific link module in your own script.
How do I safely tell an orphaned link row apart from a live one?
Query the raw link table through the Query module using the link's entryPoint and expand both sides. A row is already soft-deleted if deleted_at is set. Otherwise cross-check the joined ids against each owning module's own retrieve route, for example GET /admin/products/{id}; a 404 on one side while deleted_at is still null confirms a true orphan that is safe to hard-delete.
Related field notes
Citations
On the problem:
- [Bug]: Missing way to hard-delete link table entries. github.com/medusajs/medusa/issues/13315
- Link, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links/link
- Link API medusa v2, Discussion #10108. github.com/medusajs/medusa/discussions/10108
On the solution:
- Query, Medusa Documentation (querying a module link's table via entryPoint). docs.medusajs.com/learn/fundamentals/module-links/query
- dismissRemoteLinkStep, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/steps/dismissRemoteLinkStep
- Module Isolation, Medusa Documentation. docs.medusajs.com/learn/fundamentals/modules/isolation
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 clear an orphaned link out of your store?
If this caught a dangling link row before it broke a query in production, 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