Skip to content

Riftbound Full Sync Support + New-Game Playbook โ€” Design โ€‹

Date: 2026-07-15 Status: Approved Scope decision: Full sync enablement (parity with Pokemon/MTG). Riftbound singles only โ€” sealed products stay MTG-only and are filtered out of the transform. The effort also produces the reusable add-new-game playbook (skill + doc) with sealed requirements defined as documentation (Sections 5โ€“6).

Problem โ€‹

Riftbound is catalog-only today. The data importers (updateRiftboundCatalog.js, updateRiftboundPrices.js), npm scripts, and daily GitHub Actions workflows already exist and are at parity with Pokemon/MTG. What's missing is everything downstream of the raw catalog:

  • No Shopify-ready product collection (Pokemon has ShopifyPokemonProductVariant; MTG has ShopifyMTGProduct(Variant)). Riftbound stops at raw TCGCSV-shaped riftbound_products.
  • The Riftbound plugin (server/plugins/riftbound/index.js) deliberately throws on all sync-interface methods ("catalog/sync follow-up") and is missing the ~15 methods the Pokemon plugin implements.

Result: merchants can browse Riftbound but cannot sync it to their stores.

Approach (chosen: A โ€” mirror Pokemon) โ€‹

Considered:

  • A. Mirror the Pokemon architecture โœ… โ€” Phase-2 transform in the catalog importer writes Shopify-ready docs; plugin implements the full sync interface; identity stays on TCGplayer productId.
  • B. A + re-key prices to composite setSlug-number slugs โ€” rejected: Pokemon only re-keyed (PR #260) because its catalog and price identifiers disagreed. Riftbound's already agree (both TCGCSV productId), so this is churn plus a price migration for no benefit.
  • C. Transform raw docs at sync time, no new collection โ€” rejected: forces game special-casing into the sync core, violating the "no if (game === ...) in sync core" rule.

Section 1 โ€” Data pipeline & models โ€‹

New model server/models/ShopifyRiftboundProductVariant.js, mirroring ShopifyPokemonProductVariant field-for-field:

  • Unique handle, unique base sku, title, body, vendor, type, tags, published/status (draft โ†’ active on price arrival), productOptions (single option: Finish), variants[] (per-finish: finish, price, sku, inventoryQty, inventoryPolicy, barcode, imageUrl, shopifyVariantId), sourceSet, sourceCardId, number, rarity, shopifyProductId.
  • metafields sub-document with every field declared explicitly in the schema (ยง5.2 Silent Schema Drop), shipped with a schema-shape round-trip test modeled on ShopifyPokemonProductVariant.test.js.

Phase-2 transform in server/scripts/data-loading/updateRiftboundCatalog.js (modeled on updatePokemonData.js Phase 2):

  • Runs after the existing raw upsert phases, which are unchanged โ€” catalog browse and the sealed page keep reading riftbound_sets / riftbound_products.
  • Transforms only productType: 'card' rows (sealed excluded per scope) via plugin.transformRowToProduct(row, setDoc) into one Shopify-ready doc per card with a Finish variant per available finish.
  • Per-set replace semantics matching the Pokemon importer (delete-and-insert or dedupe-by-handle, whichever the Pokemon importer does โ€” copy its behavior).

Identity invariant (ยง5.3 Broken Join): sourceCardId = String(RiftboundProduct.tcgplayerProductId), string-equal to RiftboundPrice.slug (verified: updateRiftboundPrices.js:276 writes slug: String(productId)). Both sides come from TCGCSV; no re-keying, no migration. A comment in the importer asserts this invariant.

Finish vocabulary (ยง5.4 Invented Vocabulary): 'normal' and 'foil' โ€” copied from the SUBTYPE_TO_FINISH map in updateRiftboundPrices.js:126-127 (Normal โ†’ 'normal', Foil โ†’ 'foil'), which is what lands in paper.tcgplayer.retail. Matches the plugin's existing finishes getter.

Which finishes get variants: the raw catalog row carries no per-finish data, so the transform derives each card's finishes from the keys of its latest RiftboundPrice doc's paper.tcgplayer.retail (normal / foil). A card with no price doc yet gets a normal-only variant; the daily catalog run adds the foil variant when a foil price appears (self-healing). Variants stay data-verified rather than assumed.

Section 2 โ€” Plugin sync interface โ€‹

server/plugins/riftbound/index.js replaces its four throwing stubs and implements the full sync interface, modeled method-for-method on the Pokemon plugin:

  • Card transform: transformRowToProduct(row, setDoc) builds the flat per-card product from RiftboundProduct fields (name, number, rarity, imageUrl, cardType, domain). generateSKU / generateHandle follow Pokemon's setSlug-number[-finish] conventions. validateRow skips rows missing number/name (the sealed analog of Pokemon's extNumber check).
  • Metafields & collections: getMetafieldDefinitions() mirrors Pokemon's definition set (set code, collector number, rarity, game). getSetCollectionSpec(set) emits a smart-collection rule conditioned on the same metafield value the transform writes โ€” the metafield โ‡„ collection-rule match is validated by the end-to-end bar, not assumed.
  • Sync plumbing: getProductModel() โ†’ ShopifyRiftboundProductVariant; findSet(setCode) resolves against riftbound_sets (id / slug / name, like Pokemon's findSet); getSourceCardId(product) โ†’ product.sourceCardId; preloadPrices(cardIds) queries RiftboundPrice latest snapshot with slug โˆˆ cardIds; applyPricing(product, pricingConfig, priceCache) maps per-finish retail.normal/foil through the existing priceLookupService stages; buildVariantProducts, findExistingProduct, findExistingProductLive mirror Pokemon's implementations.
  • Minimum-price granularity: keep the BaseGamePlugin default 'rarity+finish' (6 rarities ร— 2 finishes = 12 cells, manageable like MTG). No override โ€” Pokemon's 'rarity' override exists only because of its 20+ rarities.
  • RIFTBOUND_CATEGORY_ID / TCGCSV_BASE exports are preserved.

The sync core is untouched. syncService, syncProcessor, preview, and collection creation are already plugin-driven; this work adds zero if (game === 'riftbound') branches to any of them (non-negotiable rule 5).

Section 3 โ€” API, schema & client surface (audit, not build) โ€‹

Already wired and expected to just work once the plugin implements the interface:

  • POST /api/sync validates game via isGameSupported() โ€” riftbound already passes; the Zod sync schema accepts any registered game id.
  • Catalog routes in server/routes/api.js already have riftbound branches; Store.pricingConfig.riftbound exists.
  • Client singles/set-browse pages (CatalogSinglesPage.jsx, CatalogSetBrowser.jsx) are game-generic โ€” sync buttons pass game: selectedGame through with no riftbound gating found.

Audit items in scope:

  • Verify planService.checkProductLimit and the sync route behave game-agnostically for riftbound.
  • Extend server/scripts/setupMetafieldDefinitions.js if it enumerates games.
  • Update any lingering "catalog-only" copy or gating for riftbound in the client.

Expected diff: small.

Section 4 โ€” Testing, verification & PR chain โ€‹

Tests:

  • Schema-shape round-trip test for ShopifyRiftboundProductVariant (write via model โ†’ read back โ†’ every metafield present; fails if any schema line is deleted).
  • Plugin tests expanded to cover every sync-interface method (mirroring pokemon/index.test.js).
  • Importer Phase-2 tests extended in updateRiftboundCatalog.test.js (transform output shape, sealed exclusion, finish derivation from price docs, identity-key equality).
  • โ‰ฅ70% coverage on every modified file; npm test, npm run lint clean.

Parity: the game-parity skill runs before each commit; the summary names mtg and pokemon as verified-untouched and riftbound as the change.

End-to-end bar (ยง7): POST /api/sync { game: 'riftbound', setCodes: [one set], testMode: true, testLimit: 2 } against the dev store (ufkes-dev-2.myshopify.com) succeeds AND the set's smart collection actually contains the synced products (validates metafield โ‡„ collection-rule match).

PR chain (repo convention โ€” small PRs, one theme each):

  1. PR 1 โ€” data layer (inert): ShopifyRiftboundProductVariant model + round-trip test; importer Phase-2 transform + tests. Sync still off (plugin unchanged), so this ships safely.
  2. PR 2 โ€” sync enablement: plugin sync interface + plugin tests + API/client audit fixes; end-to-end sync bar run and evidenced in the PR.
  3. PR 3 โ€” playbook: add-new-game skill + MULTI_GAME_ARCHITECTURE.md refresh + Sealed Requirements section (Sections 5โ€“6 below), written after Riftbound sync enablement proves the phases. Absorbs any client copy/gating polish discovered during PR 2's audit.

Section 5 โ€” The add-new-game playbook (skill + doc) โ€‹

Deliverable form (chosen): a new skill plus a refreshed knowledge doc, matching the game-parity pattern โ€” the skill stays thin and procedural, the doc holds the knowledge.

New skill .claude/skills/add-new-game/SKILL.md โ€” procedural, phased, checkable exit criteria per phase. Triggers: "add <game>", "support <new TCG>", creating a new directory under server/plugins/. Phases:

  • Phase 0 โ€” Feasibility & identity: data source exists (TCGCSV category id or equivalent); rarity/finish vocabulary pulled from real upstream rows, never assumed (ยง5.4); the identity-key decision made explicitly โ€” use the source's native product id when catalog and prices share one source (Riftbound: TCGplayer productId), composite setSlug-number only when the two sides disagree (Pokemon, PR #260). Exit: a written invariant statement verified by querying one real document from each feed.
  • Phase 1 โ€” Catalog + price ingest: {Game}Set/Product/Price models, importers with co-located tests, npm scripts, daily GitHub Actions workflow.
  • Phase 2 โ€” Catalog browse: API route branches, client game registration.
  • Phase 3 โ€” Shopify-ready transform: Shopify{Game}ProductVariant model with metafields declared field-by-field + schema-shape round-trip test (ยง5.2); importer Phase-2 transform.
  • Phase 4 โ€” Sync enablement: full plugin SYNC INTERFACE; zero sync-core edits (rule 5); ยง7 end-to-end bar (testMode sync + smart collection actually contains products).
  • Phase 5 (optional) โ€” Sealed: per Section 6.

MULTI_GAME_ARCHITECTURE.md updates: refresh the existing "Adding a New Game" checklist to match these phases, naming Riftbound as the canonical TCGCSV reference implementation; add Riftbound to the Game Comparison table; replace the stale 4-line "Sealed Products" sketch (which describes an approach that is not how sealed was actually built) with the Sealed Requirements of Section 6.

Section 6 โ€” Sealed Requirements (knowledge content, documentation-only in this effort) โ€‹

Context: sealed today is two disconnected things. MTG has the full lifecycle โ€” mtg_sets embedded sealed data โ†’ updateSealedProductPrices.js (hardcoded MTG_CATEGORY_ID = 1) โ†’ per-store SealedProduct draft โ†’ review โ†’ approve โ†’ active workflow (/sealed-products/* endpoints) โ†’ SealedProductMSRP fallback โ†’ per-store sealedPricingConfig. Riftbound has browse-only (riftbound_products with productType: 'sealed' on CatalogSealedPage, quick-add disabled because the endpoints read mtg_sets). Neither SealedProduct nor SealedProductMSRP has a game field.

Sealed differs from singles in kind: singles are a global Shopify-ready catalog synced per set; sealed is per-store curated inventory with MSRP-vs-market pricing. Requirements for making it game-generic:

  1. Capability declaration: plugin declares supportsSealed. Client gating (importSupported) becomes capability-driven; the server enforces the same check in the route/Zod layer (ยง5.12 โ€” never client-only).
  2. Plugin SEALED INTERFACE (mirrors the SYNC INTERFACE pattern): findSealedProducts(setCode) abstracting where the global sealed catalog lives (MTG: embedded sealedProduct[] in mtg_sets; TCGCSV games: {game}_products with productType: 'sealed'), returning a normalized shape (uuid, name, setCode, category/subtype, imageUrl); transformSealedToProduct(entry, setData) (single variant, no Finish option); preloadSealedPrices(uuids).
  3. Sealed identity invariant (ยง5.3 applied to sealed): the sealed catalog uuid must string-equal the sealed price key. Riftbound already satisfies this (String(tcgplayerProductId) โ†” RiftboundPrice.slug); MTG's mapping is documented from updateSealedProductPrices.js.
  4. Price source per game: MTG keeps its dedicated sealed price importer; TCGCSV games get sealed prices in their existing price importer (Riftbound already writes productType: 'sealed' price docs โ€” no new importer needed).
  5. game field threading: SealedProduct and SealedProductMSRP gain a required game field with no default (ยง5.5); the unique index becomes shop+game+uuid; an explicit backfill migration sets 'mtg' on existing docs (a migration script, never a schema default).
  6. MSRP applicability: MSRP is optional per game โ€” the pricing fallback chain (market โ†’ MSRP โ†’ skip) naturally handles games without MSRP data; getMSRP(game, productType, setCode).
  7. Per-game sealed pricing config: Store.sealedPricingConfig moves under per-game keying, following the migratePerGamePricingConfig.js pattern.
  8. Endpoints game-threaded: every /sealed-products/* endpoint accepts an explicit Zod-validated game and routes through the plugin interface instead of reading mtg_sets directly.
  9. Verification bar: schema-shape round-trip tests for new fields; e2e = import one sealed product in testMode โ†’ appears in Shopify as DRAFT with the correct price.

Implementing Riftbound sealed is the first application of Phase 5 and is its own future effort โ€” this spec ships the requirements as documentation only.

Out of scope โ€‹

  • Implementing Riftbound sealed (import/review flow, MSRP seeding, the Section 6 requirements) โ€” Section 6 ships as documentation; the build is a future effort.
  • Re-keying riftbound_prices slugs (composite setSlug-number) โ€” rejected approach B.
  • Any change to MTG or Pokemon plugins, models, or importers.