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:
- Designed two Mikro-ORM DML models (
TcgVariantMetadata,TcgSerializedItem) covering the full Phase 2 product-type scope. - Linked them to
productVariantwithdefineLink()(1:1 and N:1 respectively). - Seeded realistic fixtures across Pokémon / YGO / One Piece / accessories.
- Ran stock-location transfer scenarios exercising Medusa's native inventory API.
- 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.ts—TcgVariantMetadata, nullable polymorphic fields discriminated byproduct_type.src/modules/tcg/models/serialized-item.ts—TcgSerializedItem, one row per physical slab / premium raw.src/modules/tcg/service.ts—TcgModuleServicewithreserveSerializedItem. 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 withdeleteCascade.src/links/product-variant-serialized-item.ts— N:1 (isList) withdeleteCascade.- Two migrations:
Migration20260422065551(auto-generated tables + indexes) andMigration20260422065552_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) +TcgSerializedItemwith grader/grade/cert —SLAB-PSA-10-CHARIZ-BASE-001confirmedis_graded=t, grader=PSA, grade=10. - ✅ Ungraded premium raw via
TcgSerializedItemwithis_graded=false+condition_notes—RAW-PREMIUM-CHARIZ-BASE-001confirmed. - ✅ 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_itemandproduct.product_variant <> tcgModuleService.tcg_variant_metadataboth 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.
Gap 5 — Variant explosion is manageable at MVP scope, but trends visible¶
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.
TCGChannelListingdeferred 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=falseon the base variant; real inventory inTcgSerializedItem. - Reservation concurrency —
@InjectManager()+@MedusaContext()decorator pair inTcgModuleService.reserveSerializedItem(the Medusa 2.x substitution for the absentwithTransactionhelper), 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+¶
-
Shopee connector resolution of serialized items. How does the connector distinguish a listing backed by a fungible variant (decrement
inventory_level) vs a slab (flipTcgSerializedItem.status)? Probably needs aTCGChannelListing.target_typediscriminator ('variant' | 'serialized') — decide in Phase 3. -
TCGChannelListingschema shape. Driven by Shopee connector needs. Listing-level overrides (per-channel title / shipping) — separateTCGListingOverridetable, or columns onTCGChannelListing? Open. -
Send-to-PSA UX. Admin action: pick a fungible variant, decrement stock by 1, create
TcgSerializedItemrow bound to the same base variant, generate print-ready shipping label. Phase 5 UX. -
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). -
Should
yarn test:integration:modulesrun 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.