Skip to content

Phase 2: Medusa Fit Validation — Findings

Status: Complete ✅ (staging validated 2026-04-22) Predecessor: Phase 2 Kickoff Plan · Implementation Plan Exit criterion target: "TCG taxonomy maps cleanly onto Medusa primitives with documented gaps."

1. Purpose + method

Prove Medusa can model a real TCG catalogue — singles, sealed, graded, and accessories — using product / productVariant / inventory_item / stock_location primitives, extended only where the domain is genuinely custom.

Method:

  1. Designed two Mikro-ORM DML models (TcgVariantMetadata, TcgSerializedItem) covering the full Phase 2 product-type scope.
  2. Linked them to productVariant with defineLink() (1:1 and N:1 respectively).
  3. Seeded realistic fixtures across Pokémon / YGO / One Piece / accessories.
  4. Ran stock-location transfer scenarios exercising Medusa's native inventory API.
  5. Queried all fixtures via the Module Link graph to verify joins work.

2. Medusa primitives leaned on

  • product, productVariant — base catalogue structure.
  • inventory_item, inventory_level — per-location stock for fungible variants.
  • stock_location — Warehouse / Store / Event-A.
  • sales_channel — default sales channel bound to all three locations.
  • link (ContainerRegistrationKeys.LINK) — programmatic Module Link creation during seed.
  • Query API — query.graph({ entity, fields: [..., "tcg_variant_metadata.*", "tcg_serialized_items.*"] }).

3. Custom extensions added

  • src/modules/tcg/models/variant-metadata.tsTcgVariantMetadata, nullable polymorphic fields discriminated by product_type.
  • src/modules/tcg/models/serialized-item.tsTcgSerializedItem, one row per physical slab / premium raw.
  • src/modules/tcg/service.tsTcgModuleService with reserveSerializedItem. Uses @InjectManager() + @MedusaContext() decorators (the Medusa 2.x idiomatic transactional pattern) to run the list and update inside the same DB transaction.
  • src/links/product-variant-tcg-metadata.ts — 1:1 with deleteCascade.
  • src/links/product-variant-serialized-item.ts — N:1 (isList) with deleteCascade.
  • Two migrations: Migration20260422065551 (auto-generated tables + indexes) and Migration20260422065552_add_serialized_item_check (hand-written CHECK constraint: status = 'reserved' ⇒ reserved_by_order_id NOT NULL).

Shipped in PRs: - ExzenTCG/tcg-platform#11 — module scaffold + links - ExzenTCG/tcg-platform#12 — seed fixtures + integration test - ExzenTCG/tcg-platform#13 — stock-location scenarios

Execution corrections applied during rollout (PRs #14, #15, #16 — fixes, not new features): - yarn medusa db:generate takes the service key (tcgModuleService), not the folder name. - DML chain order is .index().nullable() (not the reverse). - photo_urls typed as Record<string, unknown> via model.json() — seed passes null instead of an empty array. - Runtime method name createTcgVariantMetadata (no trailing s) differs from the TypeScript-generated signature — see Gap 1.

4. Verified scenarios

Staging run: 2026-04-22. Seed first run at 15:34 UTC; second run immediately after (idempotency check); stock scenarios at 16:04 UTC.

  • ✅ Singles with condition × foil × language × variation axes (Charizard OBF 4 variants, Pikachu PAF 2 variants).
  • ✅ Edition axis (YGO Blue-Eyes 1st Ed vs Unlimited).
  • ✅ Variation axis (One Piece Luffy Normal vs Alt Art).
  • ✅ Language-as-product for sealed (Pokémon Surging Sparks EN box + Paldean Fates JP box as separate products).
  • ✅ Sealed formats: booster pack, booster box, ETB.
  • ✅ Graded slab via placeholder variant (manage_inventory=false) + TcgSerializedItem with grader/grade/cert — SLAB-PSA-10-CHARIZ-BASE-001 confirmed is_graded=t, grader=PSA, grade=10.
  • ✅ Ungraded premium raw via TcgSerializedItem with is_graded=false + condition_notesRAW-PREMIUM-CHARIZ-BASE-001 confirmed.
  • ✅ Accessories as plain Medusa products with zero TCG metadata.
  • ✅ Stock location transfers (Warehouse ↔ Event-A) conserve total quantity — S1 set 10, S2 transferred 3 to Event-A, S3 returned 2 to Warehouse; final total across all locations: 10.
  • ✅ Module Link queries return joined data cleanly — product.product_variant <> tcgModuleService.tcg_serialized_item and product.product_variant <> tcgModuleService.tcg_variant_metadata both confirmed in boot logs.
  • ✅ Reservation state machine rejects double-reserve and reserve-of-sold — covered by unit tests in PR #11 and the CHECK constraint in Migration20260422065552_add_serialized_item_check. No staging spot-check performed (no checkout UI yet; Phase 5 concern).
  • ✅ Seed script idempotent — second run logged "already seeded, skipping" for every section.

Verification query results (post-seed):

   game    | product_type | count
-----------+--------------+-------
 one_piece | sealed       |     1
 one_piece | single       |     4
 pokemon   | graded       |     1
 pokemon   | sealed       |     4
 pokemon   | single       |     6
 yugioh    | sealed       |     1
 yugioh    | single       |     4

All counts match plan exactly.

5. Gaps / concerns surfaced

Gap 1 — Type-vs-runtime method-name divergence in Medusa 2.13.6 MedusaService

TypeScript type-generator uses naïve +s pluralization for method names (e.g. createTcgVariantMetadatas), but the runtime uses the pluralize npm library, which treats metadata as already plural — so the actual method is createTcgVariantMetadata (no trailing s). Any custom module with a model name ending in an irregular-plural English word (metadata, data, news, series, species, information) hits the same trap. TypeScript happily compiles the wrong name; failure only surfaces at runtime as createTcgVariantMetadatas is not a function.

Fixed by calling the correct runtime method names plus a small declare module extension in src/modules/tcg/types.d.ts. Worth filing upstream on medusajs/medusa. Lightweight mitigation: run yarn test:integration:modules in CI — see Open Question 5.

Gap 2 — Module-owned tables don't FK-cascade from product

product.product and module-owned tables (inventory_item, tcg_variant_metadata, tcg_serialized_item) live in separate Medusa modules linked by defineLink(). No FK cascade exists between them — DELETE FROM product via raw SQL leaves orphan inventory_item rows that block re-seeding (next createInventoryItemsWorkflow collides on a reused SKU).

Operational implication: any ops tooling that deletes products MUST go through deleteProductsWorkflow (or its REST endpoint) so the service layer fans the delete across all linked modules. Direct SQL deletes are not safe for production cleanup. For dev/staging recovery, docker compose down -v is cleaner than targeted SQL.

Gap 3 — CT 105 rootfs (20 GB) is tight against repeated rebuild cycles

After ~6 image rebuilds during Phase 2 iteration, /dev/mapper/pve-vm--105--disk--0 hit 50% full (~9.1 GB used, 6.7 GB of it reclaimable Docker build cache, 1.7 GB stale images). docker system prune -af --volumes recovered 6.9 GB cleanly. The 20 GB sizing assumed "one built image" and didn't account for BuildKit cache bloat across iterations. Either bump rootfs to 40 GB, or codify docker system prune -af as a standard post-merge cleanup step. Tracked in the staging runbook.

Gap 4 — Redis fake-instance log noise despite correctly wired modules

Even with all four Redis-backed modules (event-bus-redis, workflow-engine-redis, caching, locking-redis) correctly wired and confirmed connecting, Medusa still logs redisUrl not found. A fake redis instance will be used. multiple times per process spawn (once per CLI invocation: db:migrate, db:sync-links, start, exec). The actual modules connect fine — subsequent log lines confirm Connection to Redis in module 'event-bus-redis' established. Likely a default-module init message fired before the modules: [] overrides apply. Upstream logging bug; no functional impact.

Charizard OBF 223 has 4 variants (condition × foil × language). The axis cardinality (condition={NM,LP,MP,HP,DMG}, foil={Y,N}, language=10 ISO codes, edition={1st, unlimited, shadowless, ...}) multiplies fast: a single Pokémon card listed across all conditions × EN+JP × normal+reverse-holo would produce 20 variants. Flag for Phase 5 UX decision — custom variant-management widget, or filter-and-paginate the standard Medusa table.

Gap 6 — Serialized reservation bypasses Medusa's native inventory module

TcgModuleService.reserveSerializedItem flips TcgSerializedItem.status directly; Medusa's inventory_level.reserved_quantity is untouched. Expected (serialized items deliberately live outside Medusa inventory — manage_inventory=false on the placeholder variant), but Phase 5's checkout flow must explicitly reconcile: a successful order for a serialized item must (a) flip slab status available → reserved → sold and (b) NOT touch the placeholder variant's inventory. Document the expected flow before Phase 5 builds the checkout UI.

Gap 7 — Bulk stock adjustments remain a Phase 5 tool need

Confirmed during staging runs — Medusa's admin expects one-by-one edits. TCG operators think in "+20 NM after opening a box." Not a Phase 2 blocker; listed here because stock scenarios surfaced it.

Gap 8 — Per-channel pricing deferred to Phase 4

Not addressed; assumption still holds.

Gap 9 — Product search not TCG-aware

Standard Medusa search doesn't join tcg_variant_metadata or tcg_serialized_item. Querying "charizard psa 10" returns nothing out of the box. Phase 5 custom endpoint needed.

Gap 10 — Join table name auto-generated and verbose

product_product_variant_tcgmodule_tcg_variant_metadata (66 chars) — confirmed in boot logs. Never touched by consumer code (always accessed via query.graph({ fields: ["tcg_variant_metadata.*"] })). Acceptable.

6. Phase 2 decisions recorded

  • Two tables, not three. TCGChannelListing deferred to Phase 3 (shape driven by Shopee connector needs).
  • Accessories carry no TcgVariantMetadata. Zero TCG module involvement — proven in the seed + query.
  • Language-as-product for sealed. EN and JP boxes are separate products. Cleaner for connectors than language-as-variant.
  • Graded slabs use a placeholder variant. manage_inventory=false on the base variant; real inventory in TcgSerializedItem.
  • Reservation concurrency@InjectManager() + @MedusaContext() decorator pair in TcgModuleService.reserveSerializedItem (the Medusa 2.x substitution for the absent withTransaction helper), plus a CHECK constraint as a DB-level backstop.
  • Per-game extensions deferred. Pokémon HP, Magic mana_cost, YGO level, One Piece power — none modelled. Re-evaluate Phase 5.
  • CI gap — integration tests not run at PR time; the pluralization trap in Gap 1 was caught only at staging runtime. Listed as Open Question 5 for Phase 3 prep.

7. Open questions for Phases 3+

  1. Shopee connector resolution of serialized items. How does the connector distinguish a listing backed by a fungible variant (decrement inventory_level) vs a slab (flip TcgSerializedItem.status)? Probably needs a TCGChannelListing.target_type discriminator ('variant' | 'serialized') — decide in Phase 3.

  2. TCGChannelListing schema shape. Driven by Shopee connector needs. Listing-level overrides (per-channel title / shipping) — separate TCGListingOverride table, or columns on TCGChannelListing? Open.

  3. Send-to-PSA UX. Admin action: pick a fungible variant, decrement stock by 1, create TcgSerializedItem row bound to the same base variant, generate print-ready shipping label. Phase 5 UX.

  4. Variant title readability. Auto-generated titles for the Charizard OBF 4-variant product read as "Variant 1 / Variant 2 / ..." in Medusa's admin by default. Either seed sets explicit titles (already done in PR #12) or Phase 5 admin widget renders a TCG-native title from the metadata (Charizard ex · NM · EN · Normal).

  5. Should yarn test:integration:modules run in CI? Current CI runs only type-check + docker smoke build. The pluralization trap in Gap 1 would have been caught at PR time instead of at staging runtime. Cost: ~30s Postgres container spin-up per workflow run. Benefit: every runtime regression in module service methods caught pre-merge. Recommend yes; decide in Phase 3 prep.