Idempotent Seed Script
A data-seed that isn't idempotent fails or duplicates on a second run. An idempotent seed (detect existing → skip) is re-runnable safely, and a targeted grep shows what happened in one line.
The shape
/**
* Add the cert-service RAG Certification product (Spec 0015, TASK-009).
* Price is a placeholder pending Operator pricing decision — adjust in admin.
* npx medusa exec ./src/scripts/add-certification.ts
*/
import { ExecArgs, ContainerRegistrationKeys, Modules, ProductStatus } from "@medusajs/framework";
export default async function addCertification({ container }: ExecArgs) {
const productModule = container.resolve(Modules.PRODUCT);
const existing = await productModule.listProducts({ handle: "rag-certification" });
if (existing.length) {
console.log("skip: certification product already exists");
return;
}
await productModule.createProducts({ /* ... */ });
console.log("added: certification product");
}
The rules
- Detect existing before creating. Look up by a stable unique key (handle, slug, sku, external id). If it exists, skip — don't duplicate, don't error.
- Log
added/skipexplicitly. A silent seed is un-auditable; an explicitconsole.log("added: …")orconsole.log("skip: …")makes the outcome grep-able. - Cite the spec + task ID in the header.
(Spec 0015, TASK-009)— traceability from the seed back to the tracker (seefollow-procedure). - Document placeholders pending a decision.
Price is a placeholder pending Operator pricing decision — adjust in admin(seedocument-non-action). A placeholder is a deferred decision, not a forgotten value. - Run with a targeted grep, not a raw output dump:
timeout 240 npx medusa exec ./src/scripts/add-certification.ts 2>&1 | tr '\r' '\n' | grep -iE "certification|error|added|skip" | tail -3timeout— bound it (a seed that hangs on a missing connection shouldn't hang forever).tr '\r' '\n'— convert progress-bar CRs to newlines (seediagnose-before-retry).grep -iE "added|skip|error|<topic>"— surface only the outcome lines.tail -3— bound the output.
When NOT to make it idempotent
- The seed is genuinely one-shot and will never re-run (rare — assume it will).
- The seed is destructive-by-design (a re-seed should overwrite). Then make it explicitly
upsert, not silentcreate.
The migration variant: check head, skip if already current
The same idempotent principle applies to schema migrations. Before running migrations, check the DB's current migration head:
- If the DB is already at head → skip the migrate. A no-op migrate on an already-current DB is wasted work and can still error on a partially-applied state.
- If the DB is behind head → run the migrate (it'll apply the pending set).
This is the affected-set principle applied to migrations: don't redo what's already applied. Pair with a head-check command (alembic current, prisma migrate status, medusa db:migrate --skip if supported) before the migrate.
Anti-patterns
- Bare
createProductswith no existence check. Re-run → duplicate product, broken catalog. - Silent success. The seed ran, but no log line — you can't tell from the output whether it added, skipped, or did nothing.
- No spec/task citation. The seed exists but isn't traceable to why it was written.
- Raw output dump. A medusa exec log is hundreds of lines; the one line you care about (
added/skip) is buried. - Hardcoded final value for a pending decision. Price set to a real number with no "placeholder pending decision" note → the Operator doesn't know to adjust it.
Pair with
document-non-action— the placeholder-pending-decision is a documented deferred value.diagnose-before-retry— thetimeout | tr | grep | tailrun idiom is shared.policy-as-config— a seed that writes policy-governed data (products, consent) should encode the policy fields, not just the rows.