Appearance
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 asx.test.js. - Never default
gameat runtime (CLAUDE.md ยง5.5) โ the newgamefield onSealedProduct/SealedProductMSRPhas no schema default. It's deliberately left non-requiredin 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 equalPokemonPrice.slugfor sealed items โ both keyed on the raw TCGCSVproductId. 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.extNumberis truthy, sealed otherwise. This isn't a new assumption โupdatePokemonPrices.js:157already documents it inline (// sealed products lack a card number) and the existing card-filter atupdatePokemonData.js:195already 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()(seeShopifyPokemonProductVariant.test.jsfor the pattern this codebase uses โ no live DB connection needed). - โฅ70% coverage on all four metrics for every modified file;
npm testandnpm run lintexit 0 before each commit. Husky pre-commit runs ESLint โ never--no-verify. - Zero edits to
server/routes/api.js,syncService,syncProcessor, or any plugin'ssupportsSealed/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 mainand mergeorigin/mainif 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-parityskill 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 onSealedProduct/SealedProductMSRPis 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(collectionpokemon_products), fieldstcgplayerProductId(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/_resetDepsfor injecting mock models in tests (mirrorsupdateRiftboundCatalog.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:
PokemonProductmodel (Task 1).Produces: exported
classifyRowType(row)โ'card' | 'sealed'; exportedbuildSealedProductDoc(row, group)โ PokemonProduct-shaped plain object ornull. 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 valuePokemonProduct.tcgplayerProductIdstringifies 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
rowsToDocumentsto 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 (needssupportsSealed+ 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 (
gamefield 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 (
supportsSealedcapability, MSRP fallback chain, per-gamesealedPricingConfig, 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.
