Appearance
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 hasShopifyMTGProduct(Variant)). Riftbound stops at raw TCGCSV-shapedriftbound_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-numberslugs โ rejected: Pokemon only re-keyed (PR #260) because its catalog and price identifiers disagreed. Riftbound's already agree (both TCGCSVproductId), 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 basesku,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. metafieldssub-document with every field declared explicitly in the schema (ยง5.2 Silent Schema Drop), shipped with a schema-shape round-trip test modeled onShopifyPokemonProductVariant.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) viaplugin.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 fromRiftboundProductfields (name,number,rarity,imageUrl,cardType,domain).generateSKU/generateHandlefollow Pokemon'ssetSlug-number[-finish]conventions.validateRowskips rows missingnumber/name(the sealed analog of Pokemon'sextNumbercheck). - 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 againstriftbound_sets(id / slug / name, like Pokemon'sfindSet);getSourceCardId(product)โproduct.sourceCardId;preloadPrices(cardIds)queriesRiftboundPricelatest snapshot withslug โ cardIds;applyPricing(product, pricingConfig, priceCache)maps per-finishretail.normal/foilthrough the existingpriceLookupServicestages;buildVariantProducts,findExistingProduct,findExistingProductLivemirror Pokemon's implementations. - Minimum-price granularity: keep the
BaseGamePlugindefault'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_BASEexports 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/syncvalidatesgameviaisGameSupported()โ riftbound already passes; the Zod sync schema accepts any registered game id.- Catalog routes in
server/routes/api.jsalready have riftbound branches;Store.pricingConfig.riftboundexists. - Client singles/set-browse pages (
CatalogSinglesPage.jsx,CatalogSetBrowser.jsx) are game-generic โ sync buttons passgame: selectedGamethrough with no riftbound gating found.
Audit items in scope:
- Verify
planService.checkProductLimitand the sync route behave game-agnostically for riftbound. - Extend
server/scripts/setupMetafieldDefinitions.jsif 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 lintclean.
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):
- PR 1 โ data layer (inert):
ShopifyRiftboundProductVariantmodel + round-trip test; importer Phase-2 transform + tests. Sync still off (plugin unchanged), so this ships safely. - PR 2 โ sync enablement: plugin sync interface + plugin tests + API/client audit fixes; end-to-end sync bar run and evidenced in the PR.
- PR 3 โ playbook:
add-new-gameskill + 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), compositesetSlug-numberonly 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/Pricemodels, 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}ProductVariantmodel 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:
- 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). - Plugin SEALED INTERFACE (mirrors the SYNC INTERFACE pattern):
findSealedProducts(setCode)abstracting where the global sealed catalog lives (MTG: embeddedsealedProduct[]inmtg_sets; TCGCSV games:{game}_productswithproductType: 'sealed'), returning a normalized shape (uuid, name, setCode, category/subtype, imageUrl);transformSealedToProduct(entry, setData)(single variant, no Finish option);preloadSealedPrices(uuids). - 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 fromupdateSealedProductPrices.js. - 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). gamefield threading:SealedProductandSealedProductMSRPgain a requiredgamefield with no default (ยง5.5); the unique index becomesshop+game+uuid; an explicit backfill migration sets'mtg'on existing docs (a migration script, never a schema default).- MSRP applicability: MSRP is optional per game โ the pricing fallback chain (market โ MSRP โ skip) naturally handles games without MSRP data;
getMSRP(game, productType, setCode). - Per-game sealed pricing config:
Store.sealedPricingConfigmoves under per-game keying, following themigratePerGamePricingConfig.jspattern. - Endpoints game-threaded: every
/sealed-products/*endpoint accepts an explicit Zod-validatedgameand routes through the plugin interface instead of readingmtg_setsdirectly. - 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_pricesslugs (compositesetSlug-number) โ rejected approach B. - Any change to MTG or Pokemon plugins, models, or importers.
