Reconciler Module Links & Migrations
No API to hard delete link table rows after entity removal
You deleted a product, a sales channel, or a variant, and the link table row that connected it to something else is still sitting there. You look for a way to purge it for good and there is not one. Medusa's own link.dismiss only soft-deletes the row, and link.delete only cascades when the link was configured to. Here is why Medusa keeps it that way on purpose, and a small script that finds every row this leaves behind and clears the confirmed ones safely.
Medusa v2's Module Links deliberately expose only soft-delete style operations. link.dismiss(), and the dismissRemoteLinkStep workflow step, mark a link table row, such as product_sales_channel or product_variant_inventory_item, with deleted_at rather than removing it. link.delete() only cascades when the link definition is configured to cascade, it is not a general hard-delete. Medusa's core team confirmed on GitHub that this is by design, because a workflow step that performs an irreversible delete cannot be undone by compensation if a later step fails. So when a linked entity is permanently removed straight through its module's repository, or a script that bypasses the workflow, the link table row is left behind, either live and pointing at a now-missing id, or soft-deleted and never purged. Run a small Python or Node.js script that resolves the pivot service directly, classifies every row against the live parent ids, and only hard-deletes the confirmed orphans under a dry run guard. Full code, tests, and citations are below.
The problem in plain words
When you connect two Medusa modules with a link, the relationship lives in its own pivot table, separate from both modules. That table has a deleted_at column and Medusa's public API only ever writes to it in one direction: it can mark a row deleted, and it can cascade a delete when the link definition says to. There is no third operation that says "remove this row from the table entirely, no matter what."
That gap is not an oversight. Every built-in Medusa workflow step is designed to be reversible through compensation, so if a later step in the same workflow fails, an earlier step can undo itself. An irreversible hard delete of a pivot row cannot be undone that way, so no core step is allowed to perform one. The cost is that once an entity on one side of a link is gone for good, whatever is left in the link table for it can outlive the entity indefinitely, since nothing in the public API will ever clear it.
Why it happens
The gap is structural, not a bug you can configure away. A few common ways link tables end up carrying dead rows:
- A product, sales channel, or variant is deleted through its module's repository directly, or in a script that never routes through the entity's normal delete workflow, so
dismissRemoteLinkStepis never invoked for its link rows. link.dismiss()is called, which setsdeleted_aton the row, but nothing afterward ever hard-deletes it, since no public method exists to do that, so soft-deleted rows accumulate indefinitely.- A link definition's cascade option only removes rows for entities deleted through Medusa's own delete paths. An entity removed outside those paths leaves the link row live, still pointing at an id that no longer resolves to anything.
- A migration, seed reset, or bulk cleanup script deletes rows straight from a module's table, skipping the link layer entirely no matter how the link itself is configured.
This is a common source of confusion because the store still looks correct on the surface. Queries that join through the link may silently drop rows once the parent is gone, or worse, some code paths may still surface a stale row before anyone notices. Medusa's core team confirmed on GitHub that this is by design, not a bug, because a hard delete of a pivot row cannot be reversed by workflow compensation. See the citations at the end for the exact threads and docs.
A link table row cannot tell you on its own whether it is healthy, dangling, or already soft-deleted, because Medusa's public API was never meant to answer that question either. The only way to know is to resolve the underlying pivot service directly with link.getLinkModule, an undocumented escape hatch the maintainers themselves point to, list every row including soft-deleted ones, and classify each one against the set of ids that are still actually alive. That classification is a pure decision function, not a Medusa bug to patch, so the fix is a reconciliation script that reports first and only hard-deletes what a human has confirmed.
The fix, as a flow
We do not touch a live link operation. We add a job that resolves the pivot service for a known module pair, lists every row including soft-deleted ones, fetches the live ids on both sides, and classifies each row as active, soft-deleted, or dangling. Anything not already fine is reported, and only hard-deleted through the pivot service's own delete method when a human turns 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
// 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
Resolve the pivot service directly, inside a medusa exec script
This part cannot run from an outside script over HTTP, since getLinkModule is a container-level API. Run it from a medusa exec script so it can resolve the Link module and the pivot service for the pair you are checking, such as Product and Sales Channel, and list every row including the soft-deleted ones.
# This part of the fix lives in your Medusa project's TypeScript source,
# run with `npx medusa exec ./src/scripts/list-link-rows.ts`, not in the
# external Python or Node.js script below.
#
# import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
#
# export default async function listLinkRows({ container }) {
# const link = container.resolve(ContainerRegistrationKeys.LINK)
# const linkModule = link.getLinkModule(
# Modules.PRODUCT, "product_id",
# Modules.SALES_CHANNEL, "sales_channel_id",
# )
# const rows = await linkModule.list({}, { withDeleted: true })
# # rows: [{ product_id, sales_channel_id, deleted_at }, ...]
# # Write rows to a JSON file, or expose them through your own admin
# # route, so the external script in step 3 can read them over HTTP.
# }
#
# getLinkModule is undocumented for querying too, so this is the same
# workaround the Medusa maintainers point to on GitHub issue #13315.
// This part of the fix lives in your Medusa project's TypeScript source,
// run with `npx medusa exec ./src/scripts/list-link-rows.ts`, not in the
// external Python or Node.js script below.
//
// import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils";
//
// export default async function listLinkRows({ container }) {
// const link = container.resolve(ContainerRegistrationKeys.LINK);
// const linkModule = link.getLinkModule(
// Modules.PRODUCT, "product_id",
// Modules.SALES_CHANNEL, "sales_channel_id"
// );
// const rows = await linkModule.list({}, { withDeleted: true });
// // rows: [{ product_id, sales_channel_id, deleted_at }, ...]
// // Write rows to a JSON file, or expose them through your own admin
// // route, so the external script in step 3 can read them over HTTP.
// }
//
// getLinkModule is undocumented for querying too, so this is the same
// workaround the Medusa maintainers point to on GitHub issue #13315.
Pull the live parent ids and the link rows over HTTP
Page through GET /admin/products?fields=id and GET /admin/sales-channels?fields=id for the live ids on both sides, and read the link rows your step 2 exec script exposed. This external script never guesses at the pivot data itself, it only reads what the container-level script already collected.
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_live_ids(token, resource, key):
ids, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE}/admin/{resource}",
params={"fields": "id", "limit": limit, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
ids.extend(row["id"] for row in body[key])
offset += limit
if offset >= body["count"]:
return ids
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 listLiveIds(token, resource, key) {
const ids = [];
let offset = 0;
const limit = 200;
while (true) {
const url = new URL(`${BASE}/admin/${resource}`);
url.searchParams.set("fields", "id");
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
ids.push(...body[key].map((row) => row.id));
offset += limit;
if (offset >= body.count) return ids;
}
}
Classify every row with one pure function
The decision belongs in its own function that takes a row and the two live id sets, since all the I/O already happened. A soft-deleted row is always reportable, since no API will ever purge it. A live-looking row pointing at a gone parent is the dangerous case, since a query might still surface it. Everything else is fine.
def classify_link_row(row, live_left_ids, live_right_ids):
if row.get("deleted_at") is not None:
return "orphan_soft_deleted"
if row["left_id"] not in live_left_ids or row["right_id"] not in live_right_ids:
return "orphan_dangling"
return "ok"
export function classifyLinkRow(row, liveLeftIds, liveRightIds) {
if (row.deletedAt !== null) return "orphan_soft_deleted";
if (!liveLeftIds.has(row.leftId) || !liveRightIds.has(row.rightId)) return "orphan_dangling";
return "ok";
}
Report, then hard-delete confirmed orphans behind DRY_RUN
By default the script only logs each row's classification, table name, both-side ids, and a timestamp. Only if DRY_RUN=false and a human has reviewed the report should a second medusa exec script call the pivot service's own delete method with the exact orphaned ids. If getLinkModule is unavailable or unstable for a given pair, fall back to a parameterized DELETE against the specific pivot table filtered by those exact ids, never a blanket delete with no id filter.
# Hard delete, and the dismiss step before it, both run from inside Medusa,
# not from this external script. In a medusa exec script or a workflow:
#
# await link.dismiss({
# [Modules.PRODUCT]: { product_id },
# [Modules.SALES_CHANNEL]: { sales_channel_id },
# })
#
# const linkModule = link.getLinkModule(
# Modules.PRODUCT, "product_id",
# Modules.SALES_CHANNEL, "sales_channel_id",
# )
# await linkModule.delete({ product_id: [orphanedIds] })
#
# Only run the delete call once DRY_RUN=false and the report above has
# been reviewed by a human. Log every row deleted, table name, both-side
# ids, and timestamp, before removal, since this bypasses workflow
# compensation and is irreversible.
// Hard delete, and the dismiss step before it, both run from inside Medusa,
// not from this external script. In a medusa exec script or a workflow:
//
// await link.dismiss({
// [Modules.PRODUCT]: { product_id },
// [Modules.SALES_CHANNEL]: { sales_channel_id },
// });
//
// const linkModule = link.getLinkModule(
// Modules.PRODUCT, "product_id",
// Modules.SALES_CHANNEL, "sales_channel_id"
// );
// await linkModule.delete({ product_id: [orphanedIds] });
//
// Only run the delete call once DRY_RUN=false and the report above has
// been reviewed by a human. Log every row deleted, table name, both-side
// ids, and timestamp, before removal, since this bypasses workflow
// compensation and is irreversible.
Leave DRY_RUN=true so the script only reports classified rows. Hard-deleting a link row bypasses workflow compensation entirely and cannot be undone, so it only ever runs against the exact ids collected during detection, logged first, never a blanket delete against a table with no id filter.
The full code
Here is the complete script in one file for each language. It logs in, pulls every live id on both sides of a known link pair, reads the link rows a companion medusa exec script exposed, classifies each one with the pure decision function, and reports every row that is not already fine. The hard-delete call itself stays documented rather than executed here, since getLinkModule only resolves from inside a Medusa server context.
"""Classify Medusa link table rows left behind because there is no hard delete.
Medusa v2's Module Links deliberately expose only soft-delete style operations.
link.dismiss (and the dismissRemoteLinkStep workflow step) marks a link table
row with deleted_at rather than removing it, and link.delete only cascades when
the link definition is configured to. Medusa's core team confirmed on GitHub
(medusajs/medusa#13315) this is by design, not a bug, because a workflow step
must be reversible through compensation, and an irreversible hard delete of a
pivot row cannot be undone. When a linked entity is removed outside a workflow,
the matching link row is left behind, either live and pointing at a gone id, or
already soft-deleted, and no public API will ever purge either one.
This script reads the live ids on both sides of a known link pair over the
Admin API, reads the raw link rows a companion medusa exec script exposed
(since getLinkModule only resolves inside a Medusa server context), classifies
every row with a pure function, and reports every row that is not already fine.
It only reports by default. Hard-deleting a confirmed orphan must run from
inside Medusa through link.getLinkModule, so that part is documented in the
guide, not executed by this external script.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("classify_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_live_ids(token, resource, key):
ids, offset, limit = [], 0, 200
while True:
r = requests.get(
f"{BASE}/admin/{resource}",
params={"fields": "id", "limit": limit, "offset": offset},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
body = r.json()
ids.extend(row["id"] for row in body[key])
offset += limit
if offset >= body["count"]:
return ids
def list_link_rows(token):
# Exposed by a companion medusa exec script that resolved
# link.getLinkModule(...) and called linkModule.list({}, { withDeleted: true }).
# Expected shape: [{"left_id": ..., "right_id": ..., "deleted_at": ... or None}, ...]
r = requests.get(
f"{BASE}/admin/link-rows",
params={"pair": "product_sales_channel"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
return r.json()["rows"]
def classify_link_row(row, live_left_ids, live_right_ids):
if row.get("deleted_at") is not None:
return "orphan_soft_deleted"
if row["left_id"] not in live_left_ids or row["right_id"] not in live_right_ids:
return "orphan_dangling"
return "ok"
def run():
token = login()
live_left_ids = set(list_live_ids(token, "products", "products"))
live_right_ids = set(list_live_ids(token, "sales-channels", "sales_channels"))
rows = list_link_rows(token)
reportable = 0
for row in rows:
status = classify_link_row(row, live_left_ids, live_right_ids)
if status == "ok":
continue
reportable += 1
log.warning(
"Link row %s -> %s is %s. %s",
row["left_id"], row["right_id"], status,
"would report" if DRY_RUN else "confirmed, hard delete runs server-side",
)
log.info("Done. %d reportable row(s) out of %d total.", reportable, len(rows))
if __name__ == "__main__":
run()
/**
* Classify Medusa link table rows left behind because there is no hard delete.
*
* Medusa v2's Module Links deliberately expose only soft-delete style operations.
* link.dismiss (and the dismissRemoteLinkStep workflow step) marks a link table
* row with deleted_at rather than removing it, and link.delete only cascades when
* the link definition is configured to. Medusa's core team confirmed on GitHub
* (medusajs/medusa#13315) this is by design, not a bug, because a workflow step
* must be reversible through compensation, and an irreversible hard delete of a
* pivot row cannot be undone. When a linked entity is removed outside a workflow,
* the matching link row is left behind, either live and pointing at a gone id, or
* already soft-deleted, and no public API will ever purge either one.
*
* This script reads the live ids on both sides of a known link pair over the
* Admin API, reads the raw link rows a companion medusa exec script exposed
* (since getLinkModule only resolves inside a Medusa server context), classifies
* every row with a pure function, and reports every row that is not already fine.
* It only reports by default. Hard-deleting a confirmed orphan must run from
* inside Medusa through link.getLinkModule, so that part is documented in the
* guide, not executed by this external script.
*
* Guide: https://www.allanninal.dev/medusa/no-hard-delete-for-link-rows/
*/
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 classifyLinkRow(row, liveLeftIds, liveRightIds) {
if (row.deletedAt !== null) return "orphan_soft_deleted";
if (!liveLeftIds.has(row.leftId) || !liveRightIds.has(row.rightId)) return "orphan_dangling";
return "ok";
}
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 listLiveIds(token, resource, key) {
const ids = [];
let offset = 0;
const limit = 200;
while (true) {
const url = new URL(`${BASE}/admin/${resource}`);
url.searchParams.set("fields", "id");
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
ids.push(...body[key].map((row) => row.id));
offset += limit;
if (offset >= body.count) return ids;
}
}
async function listLinkRows(token) {
// Exposed by a companion medusa exec script that resolved
// link.getLinkModule(...) and called linkModule.list({}, { withDeleted: true }).
// Expected shape: [{ leftId, rightId, deletedAt: string|null }, ...]
const url = new URL(`${BASE}/admin/link-rows`);
url.searchParams.set("pair", "product_sales_channel");
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`Medusa ${res.status}`);
const body = await res.json();
return body.rows;
}
export async function run() {
const token = await login();
const liveLeftIds = new Set(await listLiveIds(token, "products", "products"));
const liveRightIds = new Set(await listLiveIds(token, "sales-channels", "sales_channels"));
const rows = await listLinkRows(token);
let reportable = 0;
for (const row of rows) {
const status = classifyLinkRow(row, liveLeftIds, liveRightIds);
if (status === "ok") continue;
reportable++;
console.warn(
`Link row ${row.leftId} -> ${row.rightId} is ${status}. ${DRY_RUN ? "would report" : "confirmed, hard delete runs server-side"}`
);
}
console.log(`Done. ${reportable} reportable row(s) out of ${rows.length} total.`);
}
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 rows get reported and which are safe to leave alone. Because classify_link_row is pure, taking only a row and the two live id sets, the test needs no network and no Medusa backend. It just feeds in fixture rows and checks the classification.
from classify_link_rows import classify_link_row
def row(**over):
base = {"left_id": "prod_1", "right_id": "sc_1", "deleted_at": None}
base.update(over)
return base
def test_ok_when_both_parents_live():
assert classify_link_row(row(), {"prod_1"}, {"sc_1"}) == "ok"
def test_orphan_dangling_when_left_parent_gone():
result = classify_link_row(row(left_id="prod_999"), {"prod_1"}, {"sc_1"})
assert result == "orphan_dangling"
def test_orphan_dangling_when_right_parent_gone():
result = classify_link_row(row(right_id="sc_999"), {"prod_1"}, {"sc_1"})
assert result == "orphan_dangling"
def test_orphan_dangling_when_both_parents_gone():
result = classify_link_row(row(left_id="prod_999", right_id="sc_999"), {"prod_1"}, {"sc_1"})
assert result == "orphan_dangling"
def test_orphan_soft_deleted_even_when_parents_live():
r = row(deleted_at="2026-07-01T00:00:00Z")
assert classify_link_row(r, {"prod_1"}, {"sc_1"}) == "orphan_soft_deleted"
def test_orphan_soft_deleted_when_parents_also_gone():
r = row(left_id="prod_999", deleted_at="2026-07-01T00:00:00Z")
assert classify_link_row(r, {"prod_1"}, {"sc_1"}) == "orphan_soft_deleted"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyLinkRow } from "./classify-link-rows.js";
const row = (over = {}) => ({ leftId: "prod_1", rightId: "sc_1", deletedAt: null, ...over });
test("ok when both parents live", () => {
assert.equal(classifyLinkRow(row(), new Set(["prod_1"]), new Set(["sc_1"])), "ok");
});
test("orphan dangling when left parent gone", () => {
const result = classifyLinkRow(row({ leftId: "prod_999" }), new Set(["prod_1"]), new Set(["sc_1"]));
assert.equal(result, "orphan_dangling");
});
test("orphan dangling when right parent gone", () => {
const result = classifyLinkRow(row({ rightId: "sc_999" }), new Set(["prod_1"]), new Set(["sc_1"]));
assert.equal(result, "orphan_dangling");
});
test("orphan dangling when both parents gone", () => {
const result = classifyLinkRow(row({ leftId: "prod_999", rightId: "sc_999" }), new Set(["prod_1"]), new Set(["sc_1"]));
assert.equal(result, "orphan_dangling");
});
test("orphan soft deleted even when parents live", () => {
const r = row({ deletedAt: "2026-07-01T00:00:00Z" });
assert.equal(classifyLinkRow(r, new Set(["prod_1"]), new Set(["sc_1"])), "orphan_soft_deleted");
});
test("orphan soft deleted when parents also gone", () => {
const r = row({ leftId: "prod_999", deletedAt: "2026-07-01T00:00:00Z" });
assert.equal(classifyLinkRow(r, new Set(["prod_1"]), new Set(["sc_1"])), "orphan_soft_deleted");
});
Case studies
A retired sales channel left thousands of link rows behind
A retailer shut down a seasonal sales channel at the end of a promotion by deleting it through a one-off script that called the module's repository directly, since it was faster than reasoning through the workflow. Every product_sales_channel row for that channel was left in place, still pointing at a sc_ id that no longer resolved to anything.
The reconciliation script resolved the pivot service, listed every row including soft-deleted ones, and classified thousands of rows as orphan_dangling in minutes. The team reviewed the report, then ran the hard-delete step from inside a medusa exec script against the exact ids, clearing the table without touching any channel still in use.
A bulk variant import piled up soft-deleted inventory links
During a catalog migration, an engineer called link.dismiss directly on thousands of stale product_variant_inventory_item rows to clear old variants quickly, expecting the rows to disappear. They stayed in the table with deleted_at set, since dismiss only ever soft-deletes, and every later query that listed the pivot table without filtering kept counting them.
Running the classifier flagged every one of them as orphan_soft_deleted regardless of whether the parent variant still existed, matching the rule that soft-deleted rows are always reportable. The team confirmed the list, then hard-deleted the confirmed batch through getLinkModule, and later queries against the pivot table stopped over-counting.
After this runs on a schedule, dangling and soft-deleted link rows get caught within a day instead of accumulating silently for months. Every hard delete stays a deliberate, logged, confirmed step through the pivot service's own escape hatch, or a narrowly filtered parameterized delete when that service is unavailable, never a blanket delete against a table Medusa's public API was never designed to purge.
FAQ
Why does Medusa never remove link table rows for good?
Medusa v2 workflows are built to be reversible through compensation, and an irreversible hard delete of a pivot row cannot be undone if a later step fails. So the only built-in operations are link.dismiss, which sets deleted_at on the row, and link.delete, which cascades only when the link definition asks for it. Neither one purges a row for good, a gap Medusa's core team confirmed is by design in medusajs/medusa#13315.
What happens to a link row when the entity it points to is deleted outside a workflow?
If a product, sales channel, or variant is removed straight through its module's repository or a script that bypasses the delete workflow, the matching link table row is never touched. It is left behind either as a live-looking row pointing at an id that no longer exists, or as an already soft-deleted row, and no public API will ever purge either one.
Is there any safe way to actually hard delete an orphaned link row?
Yes, but it uses an undocumented escape hatch, not a public API. Resolve the underlying pivot service directly with link.getLinkModule for the two modules involved, then call its own delete method with the exact orphaned ids you confirmed through detection. If that pivot service is unavailable for a given pair, fall back to a parameterized DELETE against the specific pivot table filtered by those exact ids, never a blanket delete with no id filter.
Related field notes
Citations
On the problem:
- [Bug]: Missing way to hard-delete link table entries. github.com/medusajs/medusa/issues/13315
- Link API medusa v2, Discussion #10108. github.com/medusajs/medusa/discussions/10108
- Link, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links/link
On the solution:
- dismissRemoteLinkStep, Medusa Core Workflows Reference. docs.medusajs.com/resources/references/medusa-workflows/steps/dismissRemoteLinkStep
- Link, Medusa Documentation. docs.medusajs.com/learn/fundamentals/module-links/link
- 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 dangling link row for you?
If this caught a row that was going to keep haunting your queries, 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