Skip to content

Pokemon Sealed Products โ€” Data Layer Implementation Plan (PR 1 of 2) โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give Pokemon sealed products (booster boxes, ETBs, bundles) a place to live in the database โ€” catalog rows and prices โ€” without changing any merchant-facing behavior. This is PR 1 of a 2-PR chain (mirroring the Riftbound sync-enablement effort): PR 1 is inert data plumbing; a follow-up plan adds the plugin SEALED INTERFACE, supportsSealed capability, and threads game through every /sealed-products/* route to actually reach the Phase 5 exit bar (import one Pokemon sealed product in testMode โ†’ DRAFT in Shopify with correct price).

Architecture: Pokemon's catalog importer (updatePokemonData.js) currently discards every TCGCSV row without a card number (extNumber) โ€” that's where sealed rows die today. This plan adds a new raw catalog collection, PokemonProduct (mirroring RiftboundProduct's shape), and teaches both Pokemon importers to write sealed rows there/into PokemonPrice instead of dropping them โ€” the card pipeline is untouched. It also adds an optional game field to the shared SealedProduct/SealedProductMSRP models (no default, nothing reads or requires it yet) plus a backfill migration script, staging the schema for PR 2 without touching any existing MTG route.

Tech Stack: Node 24 CommonJS (server), Vitest + ESM (tests), Mongoose, existing DI patterns (_setDeps/_resetDeps).

Global Constraints โ€‹

  • Server code is CommonJS (require); test files are ESM (import). Test files co-located as x.test.js.
  • Never default game at runtime (CLAUDE.md ยง5.5) โ€” the new game field on SealedProduct/SealedProductMSRP has no schema default. It's deliberately left non-required in this PR because no write path sets it yet; the backfill migration is an explicit one-time script, not a runtime default. PR 2 makes the field required once every write path sets it.
  • Sealed identity invariant (ยง5.3): PokemonProduct.tcgplayerProductId (stringified) must equal PokemonPrice.slug for sealed items โ€” both keyed on the raw TCGCSV productId. Verified with a real query in Task 7 (the exit bar for this PR), not assumed.
  • Card-vs-sealed classification (ยง5.4): a TCGCSV Pokemon row is a card if row.extNumber is truthy, sealed otherwise. This isn't a new assumption โ€” updatePokemonPrices.js:157 already documents it inline (// sealed products lack a card number) and the existing card-filter at updatePokemonData.js:195 already relies on the same signal. This plan only stops discarding the sealed side.
  • New Mongoose fields declared field-by-field; every new persisted field ships with a schema-shape round-trip test (ยง5.2) using new Model(data) + validateSync() (see ShopifyPokemonProductVariant.test.js for the pattern this codebase uses โ€” no live DB connection needed).
  • โ‰ฅ70% coverage on all four metrics for every modified file; npm test and npm run lint exit 0 before each commit. Husky pre-commit runs ESLint โ€” never --no-verify.
  • Zero edits to server/routes/api.js, syncService, syncProcessor, or any plugin's supportsSealed/SEALED INTERFACE in this PR โ€” that's all PR 2. If a step here seems to need a route change, stop and flag it rather than reaching into PR 2's scope.
  • Before starting: git fetch origin main and merge origin/main if this branch is behind (ยง5.10).
  • Commit messages: one imperative sentence, merchant-visible/system outcome, sentence case, no trailing period, ending with the trailer Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>.
  • The game-parity skill doesn't apply here in the usual sense (this PR adds Pokemon-only plumbing rather than editing a shared per-game field in place) but every new field on SealedProduct/SealedProductMSRP is genuinely shared across MTG/Pokemon/Riftbound โ€” call that out by name in the PR description per CLAUDE.md ยง5.1.

Task 1: PokemonProduct model + round-trip test โ€‹

Files:

  • Create: server/models/PokemonProduct.js
  • Test: server/models/PokemonProduct.test.js

Interfaces:

  • Produces: Mongoose model PokemonProduct (collection pokemon_products), fields tcgplayerProductId (Number, required, unique), groupId (Number, required), name (String, required), cleanName, imageUrl, url (String), categoryId (Number, default 3), productType (String, enum ['sealed'], default 'sealed'), fetchedAt (Date, default now). Consumed by Task 5 (updatePokemonData.js).

  • [ ] Step 1: Write the failing test

Create server/models/PokemonProduct.test.js:

javascript
/**
 * Unit tests for PokemonProduct model.
 *
 * Mirrors ShopifyPokemonProductVariant.test.js: catches schema drift (an
 * undeclared field silently dropped by Mongoose strict mode) that plain
 * object-literal mocks elsewhere can't catch.
 */

import { describe, it, expect, beforeEach } from 'vitest';

let PokemonProduct;

beforeEach(async () => {
    const module = await import('./PokemonProduct.js');
    PokemonProduct = module.default;
});

describe('PokemonProduct Model', () => {
    const validDoc = {
        tcgplayerProductId: 999888,
        groupId: 1663,
        name: 'Base Set Booster Box',
        cleanName: 'Base Set Booster Box',
        imageUrl: 'https://example.com/image.jpg',
        url: 'https://www.tcgplayer.com/product/999888'
    };

    it('accepts a valid sealed product document', () => {
        const doc = new PokemonProduct(validDoc);
        const error = doc.validateSync();
        expect(error).toBeUndefined();
    });

    it('round-trips tcgplayerProductId and defaults productType to sealed (not a mock artifact)', () => {
        const doc = new PokemonProduct(validDoc);
        expect(doc.tcgplayerProductId).toBe(999888);
        expect(doc.productType).toBe('sealed');
        expect(doc.categoryId).toBe(3);
    });

    it('requires tcgplayerProductId', () => {
        const doc = new PokemonProduct({ ...validDoc, tcgplayerProductId: undefined });
        const error = doc.validateSync();
        expect(error.errors.tcgplayerProductId).toBeDefined();
    });

    it('requires groupId', () => {
        const doc = new PokemonProduct({ ...validDoc, groupId: undefined });
        const error = doc.validateSync();
        expect(error.errors.groupId).toBeDefined();
    });

    it('requires name', () => {
        const doc = new PokemonProduct({ ...validDoc, name: undefined });
        const error = doc.validateSync();
        expect(error.errors.name).toBeDefined();
    });

    it('rejects a productType other than sealed', () => {
        const doc = new PokemonProduct({ ...validDoc, productType: 'card' });
        const error = doc.validateSync();
        expect(error.errors.productType).toBeDefined();
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/models/PokemonProduct.test.js Expected: FAIL โ€” Failed to resolve import "./PokemonProduct.js" (the model doesn't exist yet).

  • [ ] Step 3: Write the model

Create server/models/PokemonProduct.js:

javascript
/**
 * Pokemon Product Model
 * Raw TCGCSV catalog rows for Pokemon (category 3) that aren't singles cards
 * โ€” currently sealed products only. Card catalog data goes straight to
 * ShopifyPokemonProductVariant (see updatePokemonData.js); this collection
 * exists because sealed rows had nowhere else to live before a Shopify-ready
 * transform is built for them (see MULTI_GAME_ARCHITECTURE.md "Sealed
 * Products"). Shared across all merchants.
 */

const mongoose = require('mongoose');

const pokemonProductSchema = new mongoose.Schema({
    tcgplayerProductId: {
        type: Number,
        required: true,
        unique: true
    },
    groupId: {
        type: Number,
        required: true,
        index: true
    },
    name: {
        type: String,
        required: true
    },
    cleanName: String,
    imageUrl: String,
    url: String,
    categoryId: {
        type: Number,
        default: 3
    },
    productType: {
        type: String,
        enum: ['sealed'],
        default: 'sealed',
        index: true
    },
    fetchedAt: {
        type: Date,
        default: Date.now
    }
}, { strict: false });

pokemonProductSchema.index({ groupId: 1, productType: 1 });

module.exports = mongoose.model('PokemonProduct', pokemonProductSchema, 'pokemon_products');
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/models/PokemonProduct.test.js Expected: PASS (6 tests)

  • [ ] Step 5: Commit
bash
git add server/models/PokemonProduct.js server/models/PokemonProduct.test.js
git commit -m "$(cat <<'EOF'
Add PokemonProduct model to hold sealed catalog rows

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 2: Optional game field on SealedProduct โ€‹

Files:

  • Modify: server/models/SealedProduct.js
  • Modify: server/models/SealedProduct.test.js

Interfaces:

  • Produces: SealedProduct.game (String, optional, no default, no index yet). Existing MTG documents and code paths are completely unaffected โ€” nothing reads or requires this field until PR 2. Consumed by Task 4's migration script.

  • [ ] Step 1: Write the failing test

Open server/models/SealedProduct.test.js and add these two tests inside the existing describe('Model Validation', ...) block (after the 'should enforce minimum price of 0' test):

javascript
        it('accepts an optional game field', () => {
            const product = new SealedProduct({ ...validProductData, game: 'pokemon' });
            const error = product.validateSync();
            expect(error).toBeUndefined();
            expect(product.game).toBe('pokemon');
        });

        it('does not require game yet (PR 2 flips this once every write path sets it)', () => {
            const product = new SealedProduct(validProductData);
            const error = product.validateSync();
            expect(error).toBeUndefined();
            expect(product.game).toBeUndefined();
        });
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/models/SealedProduct.test.js Expected: FAIL โ€” product.game is undefined on the first new test too (both currently pass trivially since the field doesn't exist and Mongoose ignores unknown keys)... actually re-run and confirm: the first new test fails because product.game is undefined instead of 'pokemon' (Mongoose silently drops undeclared fields).

  • [ ] Step 3: Add the field to the schema

In server/models/SealedProduct.js, add the game field immediately after the shop field:

javascript
    // Store identifier (per-store isolation)
    shop: {
        type: String,
        required: true,
        index: true
    },

    // Game identifier ('mtg', 'pokemon', ...). NOT required yet โ€” no write
    // path sets it today (sealed products are MTG-only end to end). A
    // follow-up PR threads `game` through every /sealed-products/* route and
    // flips this to required (CLAUDE.md ยง5.5 โ€” no schema default, ever).
    // Existing docs get backfilled to 'mtg' by
    // server/scripts/migrations/backfillSealedProductGame.js.
    game: {
        type: String
    },

(Keep everything else in the schema โ€” uuid, name, etc. โ€” exactly as-is.)

  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/models/SealedProduct.test.js Expected: PASS (all tests, including the two new ones)

  • [ ] Step 5: Commit
bash
git add server/models/SealedProduct.js server/models/SealedProduct.test.js
git commit -m "$(cat <<'EOF'
Add optional game field to SealedProduct ahead of Pokemon sealed support

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 3: Optional game field on SealedProductMSRP โ€‹

Files:

  • Modify: server/models/SealedProductMSRP.js
  • Create: server/models/SealedProductMSRP.test.js (no test file exists for this model today)

Interfaces:

  • Produces: SealedProductMSRP.game (String, optional, no default). Consumed by Task 4's migration script.

  • [ ] Step 1: Write the failing test

Create server/models/SealedProductMSRP.test.js:

javascript
/**
 * Unit tests for SealedProductMSRP model.
 */

import { describe, it, expect, beforeEach } from 'vitest';

let SealedProductMSRP;

beforeEach(async () => {
    const module = await import('./SealedProductMSRP.js');
    SealedProductMSRP = module.default;
});

describe('SealedProductMSRP Model', () => {
    const validDoc = {
        productType: 'play_booster_box',
        msrp: 143.99,
        effectiveDate: new Date('2024-01-01')
    };

    it('accepts a valid MSRP document without game', () => {
        const doc = new SealedProductMSRP(validDoc);
        const error = doc.validateSync();
        expect(error).toBeUndefined();
        expect(doc.game).toBeUndefined();
    });

    it('accepts an optional game field', () => {
        const doc = new SealedProductMSRP({ ...validDoc, game: 'pokemon' });
        const error = doc.validateSync();
        expect(error).toBeUndefined();
        expect(doc.game).toBe('pokemon');
    });

    it('requires productType', () => {
        const doc = new SealedProductMSRP({ ...validDoc, productType: undefined });
        const error = doc.validateSync();
        expect(error.errors.productType).toBeDefined();
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/models/SealedProductMSRP.test.js Expected: FAIL โ€” the 'accepts an optional game field' test fails because doc.game is undefined instead of 'pokemon'.

  • [ ] Step 3: Add the field to the schema

In server/models/SealedProductMSRP.js, add the game field immediately after the schema's opening productType block (before setCode):

javascript
    // Game identifier ('mtg', 'pokemon', ...). NOT required yet โ€” see
    // SealedProduct.js for why. Backfilled to 'mtg' on existing docs by
    // server/scripts/migrations/backfillSealedProductGame.js.
    game: {
        type: String
    },

    // Set code (null for global defaults, specific code for overrides)
    setCode: {
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/models/SealedProductMSRP.test.js Expected: PASS (3 tests)

  • [ ] Step 5: Commit
bash
git add server/models/SealedProductMSRP.js server/models/SealedProductMSRP.test.js
git commit -m "$(cat <<'EOF'
Add optional game field to SealedProductMSRP ahead of Pokemon sealed support

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 4: backfillSealedProductGame migration script + test โ€‹

Files:

  • Create: server/scripts/migrations/backfillSealedProductGame.js
  • Test: server/scripts/migrations/backfillSealedProductGame.test.js

Interfaces:

  • Consumes: SealedProduct (Task 2), SealedProductMSRP (Task 3) โ€” both must expose .countDocuments() and .updateMany() (real Mongoose models do).

  • Produces: run({ dryRun }) โ†’ Promise<{ productCount, msrpCount, dryRun }>, plus _setDeps/_resetDeps for injecting mock models in tests (mirrors updateRiftboundCatalog.js's DI pattern).

  • [ ] Step 1: Write the failing test

Create server/scripts/migrations/backfillSealedProductGame.test.js:

javascript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { run, _setDeps, _resetDeps } from './backfillSealedProductGame.js';

describe('backfillSealedProductGame', () => {
    let SealedProduct;
    let SealedProductMSRP;

    beforeEach(() => {
        SealedProduct = {
            countDocuments: vi.fn().mockResolvedValue(3),
            updateMany: vi.fn().mockResolvedValue({ modifiedCount: 3 })
        };
        SealedProductMSRP = {
            countDocuments: vi.fn().mockResolvedValue(2),
            updateMany: vi.fn().mockResolvedValue({ modifiedCount: 2 })
        };
        _setDeps({ SealedProduct, SealedProductMSRP });
    });

    afterEach(() => {
        _resetDeps();
    });

    it('backfills game:mtg on docs missing the field', async () => {
        const result = await run({ dryRun: false });

        expect(SealedProduct.updateMany).toHaveBeenCalledWith(
            { game: { $exists: false } },
            { $set: { game: 'mtg' } }
        );
        expect(SealedProductMSRP.updateMany).toHaveBeenCalledWith(
            { game: { $exists: false } },
            { $set: { game: 'mtg' } }
        );
        expect(result).toEqual({ productCount: 3, msrpCount: 2, dryRun: false });
    });

    it('does not write in dry-run mode', async () => {
        const result = await run({ dryRun: true });

        expect(SealedProduct.updateMany).not.toHaveBeenCalled();
        expect(SealedProductMSRP.updateMany).not.toHaveBeenCalled();
        expect(result).toEqual({ productCount: 3, msrpCount: 2, dryRun: true });
    });

    it('skips the updateMany call when count is zero', async () => {
        SealedProduct.countDocuments.mockResolvedValue(0);
        await run({ dryRun: false });
        expect(SealedProduct.updateMany).not.toHaveBeenCalled();
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/scripts/migrations/backfillSealedProductGame.test.js Expected: FAIL โ€” Failed to resolve import "./backfillSealedProductGame.js"

  • [ ] Step 3: Write the migration script

Create server/scripts/migrations/backfillSealedProductGame.js:

javascript
/**
 * Migration: backfill the new `game` field on SealedProduct and
 * SealedProductMSRP with 'mtg' for every existing document โ€” sealed support
 * was MTG-only until this field was added (see server/models/SealedProduct.js
 * and SealedProductMSRP.js), so any doc without a game is unambiguously MTG's.
 *
 * Run with:
 *   node server/scripts/migrations/backfillSealedProductGame.js [--dry-run]
 *
 * Idempotent: docs that already have a `game` set are skipped.
 */
require('dotenv').config();
const mongoose = require('mongoose');

// --- Dependency injection seam for tests ---
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
    return _deps || {
        SealedProduct: require('../../models/SealedProduct'),
        SealedProductMSRP: require('../../models/SealedProductMSRP')
    };
}

const MISSING_GAME_FILTER = { game: { $exists: false } };

async function run(options = {}) {
    const dryRun = options.dryRun ?? process.argv.includes('--dry-run');
    const { SealedProduct, SealedProductMSRP } = getDeps();
    const logger = require('../../utils/logger');

    const productCount = await SealedProduct.countDocuments(MISSING_GAME_FILTER);
    logger.info(`${dryRun ? '[dry-run] Would backfill' : 'Backfilling'} SealedProduct.game`, { count: productCount });
    if (!dryRun && productCount > 0) {
        await SealedProduct.updateMany(MISSING_GAME_FILTER, { $set: { game: 'mtg' } });
    }

    const msrpCount = await SealedProductMSRP.countDocuments(MISSING_GAME_FILTER);
    logger.info(`${dryRun ? '[dry-run] Would backfill' : 'Backfilling'} SealedProductMSRP.game`, { count: msrpCount });
    if (!dryRun && msrpCount > 0) {
        await SealedProductMSRP.updateMany(MISSING_GAME_FILTER, { $set: { game: 'mtg' } });
    }

    logger.info('Migration complete', { productCount, msrpCount, dryRun });
    return { productCount, msrpCount, dryRun };
}

module.exports = { run, _setDeps, _resetDeps };

if (require.main === module) {
    (async () => {
        const mongoUri = process.env.MONGODB_URI;
        if (!mongoUri) throw new Error('MONGODB_URI not configured');
        await mongoose.connect(mongoUri);
        await run();
        await mongoose.disconnect();
    })().then(() => process.exit(0)).catch((err) => {
        console.error('Migration failed:', err);
        process.exit(1);
    });
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/scripts/migrations/backfillSealedProductGame.test.js Expected: PASS (3 tests)

  • [ ] Step 5: Commit
bash
git add server/scripts/migrations/backfillSealedProductGame.js server/scripts/migrations/backfillSealedProductGame.test.js
git commit -m "$(cat <<'EOF'
Add migration to backfill game:mtg on existing sealed product records

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 5: updatePokemonData.js stops discarding sealed rows โ€‹

Files:

  • Modify: server/scripts/data-loading/updatePokemonData.js
  • Test: server/scripts/data-loading/updatePokemonData.test.js (new โ€” this script has no test file today)

Interfaces:

  • Consumes: PokemonProduct model (Task 1).

  • Produces: exported classifyRowType(row) โ†’ 'card' | 'sealed'; exported buildSealedProductDoc(row, group) โ†’ PokemonProduct-shaped plain object or null. Card-path behavior (cardRows, transformRowToProduct, everything downstream) is byte-for-byte unchanged.

  • [ ] Step 1: Write the failing test

Create server/scripts/data-loading/updatePokemonData.test.js:

javascript
import { describe, it, expect } from 'vitest';
import { classifyRowType, buildSealedProductDoc } from './updatePokemonData.js';

describe('updatePokemonData โ€” pure helpers', () => {
    describe('classifyRowType', () => {
        it('returns "card" when extNumber is present', () => {
            expect(classifyRowType({ extNumber: '025/102' })).toBe('card');
        });

        it('returns "sealed" when extNumber is empty or missing', () => {
            expect(classifyRowType({ extNumber: '' })).toBe('sealed');
            expect(classifyRowType({})).toBe('sealed');
        });
    });

    describe('buildSealedProductDoc', () => {
        const group = { groupId: 1663, categoryId: 3 };

        it('builds a PokemonProduct-shaped doc from a sealed CSV row', () => {
            const row = {
                productId: '999888',
                name: 'Base Set Booster Box',
                cleanName: 'Base Set Booster Box',
                imageUrl: 'https://example.com/image.jpg',
                url: 'https://www.tcgplayer.com/product/999888'
            };
            const doc = buildSealedProductDoc(row, group);
            expect(doc).toMatchObject({
                tcgplayerProductId: 999888,
                groupId: 1663,
                name: 'Base Set Booster Box',
                cleanName: 'Base Set Booster Box',
                categoryId: 3,
                productType: 'sealed'
            });
        });

        it('returns null when productId is missing or non-numeric', () => {
            expect(buildSealedProductDoc({ name: 'Mystery' }, group)).toBeNull();
            expect(buildSealedProductDoc({ productId: 'abc', name: 'Mystery' }, group)).toBeNull();
        });

        it('falls back to cleanName-less name when name is missing', () => {
            const row = { productId: '111', cleanName: 'Fallback Name' };
            const doc = buildSealedProductDoc(row, group);
            expect(doc.name).toBe('Fallback Name');
        });
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/scripts/data-loading/updatePokemonData.test.js Expected: FAIL โ€” classifyRowType/buildSealedProductDoc are not exported yet (module has no module.exports, and importing it today would also try to run main() immediately โ€” see Step 3).

  • [ ] Step 3: Add the pure helpers, the sealed upsert path, and the test guard

In server/scripts/data-loading/updatePokemonData.js:

3a. Add the PokemonProduct import near the other model imports (after the ShopifyPokemonProductVariant require):

javascript
const PokemonSet = require('../../models/PokemonSet');
const ShopifyPokemonProductVariant = require('../../models/ShopifyPokemonProductVariant');
const PokemonProduct = require('../../models/PokemonProduct');
const PokemonPlugin = require('../../plugins/pokemon');

3b. Add classifyRowType and buildSealedProductDoc right after the existing buildSetDoc function:

javascript
/**
 * Classify a raw TCGCSV CSV row as 'card' or 'sealed'. Cards always carry
 * extNumber (their collector number); sealed products never do โ€” the same
 * signal updatePokemonPrices.js already uses to skip sealed rows entirely
 * (see its inline comment at the extNumber check).
 */
function classifyRowType(row) {
    return row.extNumber ? 'card' : 'sealed';
}

/**
 * Transform a sealed TCGCSV CSV row into a PokemonProduct-shaped doc.
 * Returns null for rows with no usable numeric productId.
 */
function buildSealedProductDoc(row, group) {
    const tcgplayerProductId = parseInt(row.productId, 10);
    if (!tcgplayerProductId) return null;
    return {
        tcgplayerProductId,
        groupId: group.groupId,
        name: row.name || row.cleanName || 'Unknown',
        cleanName: row.cleanName || '',
        imageUrl: row.imageUrl || '',
        url: row.url || '',
        categoryId: group.categoryId || POKEMON_CATEGORY_ID,
        productType: 'sealed',
        fetchedAt: new Date()
    };
}

/**
 * Upsert one group's sealed rows into PokemonProduct. Returns the count of
 * rows seen (written, or would-be-written under --dry-run).
 */
async function upsertSealedProducts(sealedRows, group) {
    if (sealedRows.length === 0) return 0;
    if (DRY_RUN) return sealedRows.length;

    let upserted = 0;
    for (const row of sealedRows) {
        const doc = buildSealedProductDoc(row, group);
        if (!doc) continue;
        await PokemonProduct.updateOne(
            { tcgplayerProductId: doc.tcgplayerProductId },
            { $set: doc },
            { upsert: true }
        );
        upserted++;
    }
    return upserted;
}

3c. In ingestGroup(group, stats), insert the sealed handling right after rows is fetched, before the existing // Filter to card rows comment โ€” the card path below this point stays completely unchanged:

javascript
    let rows;
    try {
        rows = await downloadGroupCSV(group.groupId);
    } catch (err) {
        log(`   โŒ ${setDoc.id} (${setDoc.name}): ${err.message}`);
        stats.groupsFailed++;
        return;
    }

    // Sealed rows (no extNumber) get a home in PokemonProduct โ€” separate
    // from the card pipeline below, which is untouched by this change.
    const sealedRows = rows.filter((r) => classifyRowType(r) === 'sealed');
    const sealedSeen = await upsertSealedProducts(sealedRows, group);
    stats.sealedSeen += sealedSeen;

    // Filter to card rows (sealed has no extNumber)
    const cardRows = rows.filter((r) => r.extNumber && r.subTypeName);

3d. In main(), add sealedSeen: 0 to the stats object initialization and log it in the summary:

javascript
    const stats = {
        groupsProcessed: 0,
        groupsFailed: 0,
        groupsSkipped: 0,
        created: 0,
        updated: 0,
        skipped: 0,
        errors: 0,
        sealedSeen: 0
    };
javascript
    log(`Products =:      ${stats.skipped}`);
    log(`Sealed seen:     ${stats.sealedSeen}`);
    log(`Errors:          ${stats.errors}`);

3e. Replace the unconditional main().catch(...) at the bottom of the file with a guarded invocation plus exports, so the file is safely importable by tests:

Replace:

javascript
main().catch((err) => {
    console.error('โŒ Fatal:', err);
    process.exit(1);
});

With:

javascript
module.exports = { classifyRowType, buildSealedProductDoc };

if (require.main === module) {
    main().catch((err) => {
        console.error('โŒ Fatal:', err);
        process.exit(1);
    });
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/scripts/data-loading/updatePokemonData.test.js Expected: PASS (5 tests)

  • [ ] Step 5: Run the full server test suite to confirm the card path is untouched

Run: npm test Expected: all suites PASS, same count as before this task (no regressions in Pokemon plugin/sync tests).

  • [ ] Step 6: Commit
bash
git add server/scripts/data-loading/updatePokemonData.js server/scripts/data-loading/updatePokemonData.test.js
git commit -m "$(cat <<'EOF'
Stop discarding Pokemon sealed catalog rows during ingest

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 6: updatePokemonPrices.js stops discarding sealed prices โ€‹

Files:

  • Modify: server/scripts/data-loading/updatePokemonPrices.js
  • Test: server/scripts/data-loading/updatePokemonPrices.test.js (new)

Interfaces:

  • Produces: exported classifyPriceRowType(row) โ†’ 'card' | 'sealed'; rowsToDocuments(rows, setId, snapshotDate) now also returns sealed price docs (productType: 'sealed', slug: String(productId)) alongside unchanged card docs. Card-doc shape and slug format (${setId}-${number}) are unchanged.

  • Sealed price docs use slug = String(productId) โ€” the same value PokemonProduct.tcgplayerProductId stringifies to (Task 1/5), satisfying the ยง5.3 identity invariant checked in Task 7.

  • [ ] Step 1: Write the failing test

Create server/scripts/data-loading/updatePokemonPrices.test.js:

javascript
import { describe, it, expect } from 'vitest';
import { classifyPriceRowType, rowsToDocuments } from './updatePokemonPrices.js';

describe('classifyPriceRowType', () => {
    it('returns "card" when extNumber is present, else "sealed"', () => {
        expect(classifyPriceRowType({ extNumber: '025/102' })).toBe('card');
        expect(classifyPriceRowType({ extNumber: '' })).toBe('sealed');
        expect(classifyPriceRowType({})).toBe('sealed');
    });
});

describe('rowsToDocuments', () => {
    const snapshotDate = new Date('2026-07-16T00:00:00Z');

    it('builds a card doc keyed on setId-number, unchanged from before', () => {
        const rows = [
            { productId: '111', extNumber: '025/102', subTypeName: 'Holofoil', marketPrice: '12.50' }
        ];
        const docs = rowsToDocuments(rows, 'tcgcsv-1663', snapshotDate);
        expect(docs).toHaveLength(1);
        expect(docs[0]).toMatchObject({ productType: 'card', slug: 'tcgcsv-1663-25', game: 'pokemon' });
        expect(docs[0].paper.tcgplayer.retail.holofoil).toBe(12.5);
    });

    it('builds a sealed doc keyed on the raw productId, not setId-number', () => {
        const rows = [
            { productId: '999888', name: 'Base Set Booster Box', marketPrice: '450.00' }
        ];
        const docs = rowsToDocuments(rows, 'tcgcsv-1663', snapshotDate);
        expect(docs).toHaveLength(1);
        expect(docs[0]).toMatchObject({ productType: 'sealed', slug: '999888', game: 'pokemon' });
        expect(docs[0].paper.tcgplayer.retail.normal).toBe(450);
    });

    it('skips sealed rows with no usable price', () => {
        const rows = [{ productId: '999888', name: 'Base Set Booster Box' }];
        const docs = rowsToDocuments(rows, 'tcgcsv-1663', snapshotDate);
        expect(docs).toHaveLength(0);
    });

    it('handles a mixed batch of card and sealed rows independently', () => {
        const rows = [
            { productId: '111', extNumber: '025/102', subTypeName: 'Holofoil', marketPrice: '12.50' },
            { productId: '999888', name: 'Base Set Booster Box', marketPrice: '450.00' }
        ];
        const docs = rowsToDocuments(rows, 'tcgcsv-1663', snapshotDate);
        expect(docs).toHaveLength(2);
        const types = docs.map((d) => d.productType).sort();
        expect(types).toEqual(['card', 'sealed']);
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/scripts/data-loading/updatePokemonPrices.test.js Expected: FAIL โ€” classifyPriceRowType is not exported, and the sealed-doc tests fail because sealed rows are currently skipped entirely (docs comes back empty).

  • [ ] Step 3: Rewrite rowsToDocuments to branch on classification instead of skipping sealed rows

In server/scripts/data-loading/updatePokemonPrices.js, add classifyPriceRowType right before rowsToDocuments:

javascript
/**
 * Classify a raw TCGCSV CSV row as 'card' or 'sealed'. Mirrors
 * updatePokemonData.js's classifyRowType โ€” same signal, same reason.
 */
function classifyPriceRowType(row) {
    return row.extNumber ? 'card' : 'sealed';
}

Replace the entire body of rowsToDocuments with:

javascript
function rowsToDocuments(rows, setId, snapshotDate) {
    const docsByProductId = new Map();

    for (const row of rows) {
        const productId = row.productId;
        if (!productId) continue;

        if (classifyPriceRowType(row) === 'card') {
            const number = normalizeCardNumber(row.extNumber);
            if (!number) continue;

            const finishKey = FINISH_MAP[row.subTypeName];
            if (!finishKey) continue;

            const price = pickPrice(row);
            if (price == null) continue;

            const slug = `${setId}-${number}`;

            if (!docsByProductId.has(productId)) {
                docsByProductId.set(productId, {
                    productType: 'card',
                    slug,
                    timestamp: snapshotDate,
                    game: 'pokemon',
                    paper: { tcgplayer: { retail: {} } }
                });
            }
            docsByProductId.get(productId).paper.tcgplayer.retail[finishKey] = price;
        } else {
            const price = pickPrice(row);
            if (price == null) continue;

            // Sealed products have no card number โ€” the price slug is the
            // raw TCGCSV productId itself, matching PokemonProduct's
            // tcgplayerProductId (updatePokemonData.js) so the sealed
            // identity invariant (ยง5.3) holds.
            const slug = String(productId);

            if (!docsByProductId.has(productId)) {
                docsByProductId.set(productId, {
                    productType: 'sealed',
                    slug,
                    timestamp: snapshotDate,
                    game: 'pokemon',
                    paper: { tcgplayer: { retail: {} } }
                });
            }
            docsByProductId.get(productId).paper.tcgplayer.retail.normal = price;
        }
    }

    // Deduplicate by slug โ€” if multiple TCGCSV productIds collapse to the same
    // slug (e.g., one card listed twice), merge their finish data.
    const bySlug = new Map();
    for (const doc of docsByProductId.values()) {
        const existing = bySlug.get(doc.slug);
        if (!existing) {
            bySlug.set(doc.slug, doc);
        } else {
            Object.assign(existing.paper.tcgplayer.retail, doc.paper.tcgplayer.retail);
        }
    }

    if (CARD_LIMIT) {
        return [...bySlug.values()].slice(0, CARD_LIMIT);
    }
    return [...bySlug.values()];
}

Replace the unconditional main().catch(...) at the bottom of the file with:

javascript
module.exports = { classifyPriceRowType, rowsToDocuments };

if (require.main === module) {
    main().catch((err) => {
        console.error('โŒ Fatal:', err);
        process.exit(1);
    });
}
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/scripts/data-loading/updatePokemonPrices.test.js Expected: PASS (6 tests)

  • [ ] Step 5: Run the full server test suite

Run: npm test Expected: all suites PASS, no regressions (priceLookupService / Pokemon plugin tests that exercise card pricing are unaffected โ€” the card branch's logic is unchanged, just moved inside an if).

  • [ ] Step 6: Commit
bash
git add server/scripts/data-loading/updatePokemonPrices.js server/scripts/data-loading/updatePokemonPrices.test.js
git commit -m "$(cat <<'EOF'
Stop discarding Pokemon sealed product prices during ingest

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"

Task 7: PR 1 exit bar โ€” verify against local Mongo (no code changes) โ€‹

This task has no commit โ€” it's the "inert" phase's proof that nothing broke and the new plumbing actually produces real, joinable data. Requires docker compose up -d running (local MongoDB on :27017) and a .env with MONGODB_URI pointed at it, per the root CLAUDE.md ยง4.

Files: none (verification only)

  • [ ] Step 1: Full suite + lint

Run: npm test Expected: exit 0, all suites pass, including the 4 new/modified files from Tasks 1โ€“6.

Run: npm run lint Expected: exit 0, zero new warnings compared to main.

  • [ ] Step 2: Dry-run the migration

Run: node server/scripts/migrations/backfillSealedProductGame.js --dry-run Expected output includes lines like:

Backfilling SealedProduct.game { count: <N> }
Backfilling SealedProductMSRP.game { count: <M> }
Migration complete { productCount: <N>, msrpCount: <M>, dryRun: true }

(<N>/<M> reflect whatever local seed data exists โ€” 0 is fine on a fresh local DB.)

  • [ ] Step 3: Run the migration for real, confirm idempotency

Run: node server/scripts/migrations/backfillSealedProductGame.js Then run it again immediately: node server/scripts/migrations/backfillSealedProductGame.js Expected: first run backfills any existing docs; second run reports count: 0 for both collections (idempotent โ€” nothing left to backfill).

  • [ ] Step 4: Ingest one Pokemon set and confirm sealed rows land in PokemonProduct

Run: node server/scripts/data-loading/updatePokemonData.js --set tcgcsv-1663 (Substitute any known-good Pokemon PokemonSet.id if tcgcsv-1663 isn't present locally โ€” check with a quick mongosh query first: db.pokemon_sets.findOne().)

Expected: summary output includes a Sealed seen: <N> line with <N> > 0 for a set that has sealed products (e.g. a modern base set with booster boxes/ETBs).

Then verify in Mongo:

mongosh $MONGODB_URI --eval "db.pokemon_products.find({ productType: 'sealed' }).limit(3).pretty()"

Expected: at least one document with tcgplayerProductId, groupId, name.

  • [ ] Step 5: Ingest prices for the same set and confirm the sealed identity invariant holds

Run: node server/scripts/data-loading/updatePokemonPrices.js --set tcgcsv-1663

Then, using one tcgplayerProductId observed in Step 4, confirm the ยง5.3 slug invariant with a real query:

mongosh $MONGODB_URI --eval "
  const pid = db.pokemon_products.findOne({ productType: 'sealed' }).tcgplayerProductId;
  print('catalog id:', pid);
  print('price doc:', JSON.stringify(db.pokemon_prices.findOne({ slug: String(pid), productType: 'sealed' })));
"

Expected: the price doc is non-null and its slug string-equals String(pid) โ€” paste this output into the PR description as the Phase 0/ยง5.3 evidence (per CLAUDE.md's quality bar: "Data-matching literals cite their source document/importer").

  • [ ] Step 6: Confirm the card path is unaffected

Run: mongosh $MONGODB_URI --eval "db.shopify_pokemon_products_variants.countDocuments({ sourceSet: 'tcgcsv-1663' })" before and after Step 4's ingest run โ€” the count should be identical (card ingestion is untouched by this PR).


Self-review โ€‹

Spec coverage against MULTI_GAME_ARCHITECTURE.md ยง"Sealed Products" (requirements 2โ€“5, the PR-1-scoped subset):

  • Req 2 (Plugin SEALED INTERFACE, findSealedProducts/transformSealedToProduct/preloadSealedPrices) โ€” explicitly out of scope, deferred to PR 2 (needs supportsSealed + route threading to be meaningful; building it now with nothing to call it would be dead scaffolding, CLAUDE.md ยง5.9).
  • Req 3 (sealed identity invariant) โ€” satisfied by construction (Task 5/6 both key on the raw productId) and verified with a real query in Task 7 Step 5.
  • Req 4 (price source per game, no new importer) โ€” satisfied: Task 6 extends the existing Pokemon price importer rather than adding a new one, matching Riftbound's precedent.
  • Req 5 (game field threading, backfill migration) โ€” satisfied for the schema + migration half (Tasks 2โ€“4); the "no default, ever" half of this requirement is satisfied by explicitly keeping the field non-required until PR 2 threads real writers.
  • Reqs 1, 6, 7, 8, 9 (supportsSealed capability, MSRP fallback chain, per-game sealedPricingConfig, game-threaded endpoints, e2e exit bar) โ€” all explicitly PR 2, called out in Global Constraints.

Placeholder scan: no TBD/"add error handling"/"similar to Task N" language; every step has literal file contents.

Type consistency: classifyRowType/classifyPriceRowType both return 'card'|'sealed' strings consistently; PokemonProduct.tcgplayerProductId (Number) vs. PokemonPrice.slug (String) โ€” the mismatch is intentional and bridged explicitly (String(productId) in Task 6, String(pid) in Task 7's verification query), matching how RiftboundProduct.tcgplayerProductId (Number) relates to RiftboundPrice.slug (String) today.