Appearance
TCGplayer Import PR 4 — Sealed Implementation Plan
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: Import the Unopened rows of a TCGplayer Pricing Custom Export as sealed products, matched to our catalog through TCGplayer's own product ids and stocked at absolute quantity.
Architecture: Sealed rows never join the singles lane. They keep match.status: 'sealed' and gain a match.sealedUuid, so PR 2's phase-3 query ('match.status': 'matched') is untouched by construction. Matching happens in the preview (runMatch) so the merchant sees sealed counts and pays for them in the tier estimate before confirming; execution is a new phase 4 in runImport that drives each matched sealed line through the existing quickAddSealedProduct with a new absolute-quantity mode. The name-to-uuid bridge is TCGCSV ProductsAndPrices.csv name -> productId -> MTGJSON identifiers.tcgplayerProductId -> uuid, fetched only for groups that actually contain sealed rows.
Tech Stack: Node 24 CommonJS server, Mongoose, Vitest (ESM tests) with the _setDeps()/_resetDeps() injection seam, BullMQ worker, React 18 + retroui client.
Spec: docs/superpowers/specs/2026-08-04-tcgplayer-inventory-import-design.md (section "Sealed matching (PR 4)", Phasing row 4)
Global Constraints
- The join key is a string on both sides. TCGCSV
ProductsAndPrices.csvyieldsrow.productIdas a string; MTGJSON'sset.data.sealedProduct[].identifiers.tcgplayerProductIdis a string. The per-storeSealedProduct.identifiers.tcgplayerProductIdis typedNumber(server/models/SealedProduct.js:54, written viaparseIntatserver/plugins/mtg/index.js:537). Never compare these withoutString(...)on both sides — a Number/string mismatch does not throw, it silently matches nothing (§5.4, §5.3). Every map key and every lookup in this PR isString(id).trim(). - Sealed rows keep
match.status: 'sealed'. Do not reuse'matched'.runImportphase 3 selects{'match.status': 'matched'}and runsdistinct('match.setCode')on it (server/services/tcgImportRunService.js:227); promoting a sealed row to'matched'would push it intosyncSetDirectas if it were a card. gameis never defaulted — it is read fromimportDoc.gameand passed explicitly (§5.5). No|| 'mtg'anywhere in the diff.- All Shopify traffic goes through
server/services/shopifyAPI.js(rule 6).setInventoryQuantity(inventoryItemId, locationId, quantity)already exists atshopifyAPI.js:1643— do not add a second absolute-inventory primitive. - No price-lock work for sealed. Verified:
priceUpdateService,priceUpdateProcessorand every file underserver/queues/contain zero references toSealedProduct, and no service bulk-rewritescurrentPrice. Nothing automatically re-prices a sealed product, so PR 3'sprice_lockedmetafield has nothing to protect here.priceModedecides which price is written at create time and nothing else. Do not add a sealed price-lock metafield (§5.9 — nothing would read it). - Parity (§5.1): MTG-only by decision, unchanged from PRs 1–3.
pokemonandriftboundare exempt — unverified: no real Pokemon or Riftbound export exists, so theProduct Lineliteral they write is unknown and inventing it is the §5.4 failure mode. The classifier already routes unrecognized product lines tounsupported_line. The sealed path additionally inherits the existingplugin.supportsSealedgate inquickAddSealedProduct(server/services/sealedQuickAddService.js:284). - Coverage: every modified file ≥70% on all four metrics, read from the per-file table by hand — the Vitest threshold gate is silently inoperative (§7).
- Reads must not materialise unbounded sets (§5.6). Sealed lines are bounded (4 rows in the reference export) and may use
find().lean(); the sealed catalog scan is bounded by set count and already.lean().
Task 1: Verify the join on real documents
The spec gates this whole PR on one unverified assumption: that real catalog documents carry identifiers.tcgplayerProductId. If they don't, the fallback is setCode + exact name and the rest of the plan changes. This task writes no product code — it produces evidence.
Files:
- Create:
server/scripts/verify/verifySealedTcgplayerIds.js(throwaway verification script, deleted in Task 7)
Interfaces:
Consumes:
server/models/SetModel.js(the MTG sealed catalog lives atset.data.sealedProduct[]).Produces: evidence only — a coverage percentage and a
typeofhistogram quoted in the commit message. No code other tasks import.[ ] Step 1: Confirm which database
.envpoints at
.env is sometimes set to the production Atlas connection string (§8). This script is read-only so either is safe, but you must know which you measured.
bash
node -e "require('dotenv').config(); const u=process.env.MONGODB_URI||''; console.log(u.includes('localhost')?'LOCAL':'REMOTE/ATLAS', u.replace(/:[^:@]+@/,':***@'))"Expected: prints LOCAL or REMOTE/ATLAS plus the redacted URI. Record which one in the commit message.
- [ ] Step 2: Write the verification script
js
/**
* Throwaway: proves the PR 4 join key exists on real catalog documents.
* Read-only. Deleted in Task 7 once the real tests encode the same facts.
*/
require('dotenv').config();
const mongoose = require('mongoose');
const SetModel = require('../../models/SetModel');
(async () => {
await mongoose.connect(process.env.MONGODB_URI);
const sets = await SetModel.find({ 'data.sealedProduct.0': { $exists: true } })
.select('data.code data.sealedProduct').lean();
let total = 0;
let withId = 0;
const types = new Map();
const samples = [];
for (const set of sets) {
for (const product of (set.data.sealedProduct || [])) {
total++;
const id = product.identifiers && product.identifiers.tcgplayerProductId;
if (id === undefined || id === null || id === '') continue;
withId++;
types.set(typeof id, (types.get(typeof id) || 0) + 1);
if (samples.length < 5) {
samples.push({ set: set.data.code, name: product.name, uuid: product.uuid, id, type: typeof id });
}
}
}
console.log(`sets with sealed: ${sets.length}`);
console.log(`sealed products: ${total}`);
console.log(`carrying tcgplayerProductId: ${withId} (${((withId / total) * 100).toFixed(1)}%)`);
console.log('typeof histogram:', Object.fromEntries(types));
console.log('samples:', JSON.stringify(samples, null, 2));
await mongoose.disconnect();
})();- [ ] Step 3: Run it and read the output
bash
node server/scripts/verify/verifySealedTcgplayerIds.jsExpected: a coverage percentage and a typeof histogram. The histogram is the deliverable. Paste the real output into the commit message.
Decision gate — do not skip:
Coverage is high and
typeofisstring→ proceed to Task 2 as written.Coverage is high but
typeofisnumber→ still proceed; theString()normalization in Tasks 3 and 4 already covers it, and Task 4 Step 1's test pins both. Note it in the commit.Coverage is low (most sealed products carry no id) → stop and report. The spec's fallback is
setCode+ exact name against the catalog, which changes Tasks 3 and 4 materially. Bring the numbers back before writing code.[ ] Step 4: Commit the evidence
bash
git add server/scripts/verify/verifySealedTcgplayerIds.js
git commit -m "Verify sealed catalog products carry TCGplayer product ids"Task 2: Absolute-quantity mode for sealed quick-add
Files:
- Modify:
server/services/sealedQuickAddService.js(the twoif (quantity > 0)inventory blocks — one in the existing-product branch, one in the new-product branch — plus the function's JSDoc) - Test:
server/services/sealedQuickAddService.test.js
Interfaces:
Consumes:
shopifyAPI.setInventoryQuantity(inventoryItemId, locationId, quantity)(server/services/shopifyAPI.js:1643), already shipped in PR 2.Produces:
quickAddSealedProduct({ shop, store, accessToken, game, uuid, quantity, price, quantityMode })wherequantityModeis'add'(default — existing additive behaviour) or'set'(absolute). Return shape unchanged:{ created, product, priceUpdated, quantityAdded, quantityWarning }.[ ] Step 1: Write the failing tests — both behaviours
The spec requires a test for each mode; the additive test is the regression guard for the catalog quick-add UI, which means "add N more". Follow the existing file's stubbing style rather than inventing one — it already builds a ShopifyAPI stub for these paths.
js
describe('quickAddSealedProduct quantityMode', () => {
it('defaults to additive inventory so the catalog quick-add keeps meaning "add N more"', async () => {
const shopifyAPI = makeShopifyAPIStub();
await quickAddSealedProduct({ ...baseArgs, quantity: 3 });
expect(shopifyAPI.addInventoryQuantity).toHaveBeenCalledWith('gid://inv/1', 'gid://loc/1', 3);
expect(shopifyAPI.setInventoryQuantity).not.toHaveBeenCalled();
});
it('sets absolute inventory when quantityMode is "set" so a re-run is idempotent', async () => {
const shopifyAPI = makeShopifyAPIStub();
await quickAddSealedProduct({ ...baseArgs, quantity: 3, quantityMode: 'set' });
expect(shopifyAPI.setInventoryQuantity).toHaveBeenCalledWith('gid://inv/1', 'gid://loc/1', 3);
expect(shopifyAPI.addInventoryQuantity).not.toHaveBeenCalled();
});
});Write the equivalent pair for the new-product branch (no existing SealedProduct document), since the two inventory blocks are separate code paths and a change applied to only one is exactly the §5.8 shape.
- [ ] Step 2: Run to verify they fail
bash
npx vitest run server/services/sealedQuickAddService.test.jsExpected: FAIL — setInventoryQuantity is never called.
- [ ] Step 3: Implement
Add the parameter with an explicit default:
js
async function quickAddSealedProduct({ shop, store, accessToken, game, uuid, quantity, price, quantityMode = 'add' }) {Extract the write so both branches share it — the two blocks are already near-duplicates, and adding a mode to each independently is how they drift (§5.8):
js
/**
* Apply a quantity to a sealed variant.
*
* 'add' is the default because that is what the catalog quick-add UI means: a
* merchant typing 3 has three more boxes on the shelf. The TCGplayer import
* passes 'set' — its Total Quantity column is the merchant's whole on-hand
* count, so re-running the same file must converge rather than double the shelf.
*/
async function applySealedQuantity(shopifyAPI, shopifyVariantId, quantity, quantityMode) {
const inventoryItemId = await shopifyAPI.getVariantInventoryItemId(shopifyVariantId);
const locationId = await shopifyAPI.getDefaultLocationId();
if (!locationId || !inventoryItemId) {
throw new Error('No active Shopify location found');
}
if (quantityMode === 'set') {
await shopifyAPI.setInventoryQuantity(inventoryItemId, locationId, quantity);
} else {
await shopifyAPI.addInventoryQuantity(inventoryItemId, locationId, quantity);
}
}Replace the body of both if (quantity > 0) blocks with a call to it, keeping each block's existing try/catch, its distinct quantityWarning copy, and quantityAdded = quantity.
- [ ] Step 4: Run tests
bash
npx vitest run server/services/sealedQuickAddService.test.js server/services/buylistIntakeService.test.jsExpected: PASS. buylistIntakeService is the existing second caller — it passes no quantityMode and must keep additive behaviour.
- [ ] Step 5: Commit
bash
git add server/services/sealedQuickAddService.js server/services/sealedQuickAddService.test.js
git commit -m "Let a sealed quick-add set an absolute quantity instead of adding to it"Task 3: TCGCSV sealed name-to-product-id resolver
Files:
- Create:
server/services/tcgcsvSealedService.js - Modify:
server/services/tcgcsvGroupService.js(add and exporthttpsGetText) - Test:
server/services/tcgcsvSealedService.test.js
Interfaces:
Consumes:
REQUEST_OPTIONSandREQUEST_TIMEOUT_MSalready intcgcsvGroupService.js;parseCsvRowsfromserver/utils/tcgplayerCsv.js.Produces:
resolveSealedProductIds(groupIds)→Promise<Map<number, Map<string, string>>>— outer key is the groupId, inner map is normalized product name → productId as a string. Also exportsnormalizeName(name) => string(Task 4 uses it to normalize the CSV'sproductName), plus_setDeps({ httpGet, redis })/_resetDeps().[ ] Step 1: Confirm
parseCsvRows's real signature
tcgImportService.js calls await parseCsvRows(csvText). Confirm that is what server/utils/tcgplayerCsv.js exports before writing against it — do not adapt the call from memory (§5.4).
bash
grep -n "parseCsvRows" server/utils/tcgplayerCsv.jsExpected: a function taking CSV text and returning parsed row objects. If the signature differs, match the real one.
- [ ] Step 2: Write the failing tests
js
import { describe, it, expect, afterEach } from 'vitest';
import service from '../../server/services/tcgcsvSealedService.js';
const CSV = [
'productId,name,cleanName,extUPC,marketPrice',
'522559,Secret Lair Drop: Calling All Hydra Heads,Calling All Hydra Heads,,44.21',
'123456,Ugins Fate Booster Pack,Ugins Fate Booster Pack,,9.99'
].join('\n');
afterEach(() => service._resetDeps());
describe('resolveSealedProductIds', () => {
it('returns product ids as strings, keyed by normalized name', async () => {
service._setDeps({ httpGet: async () => CSV, redis: null });
const group = (await service.resolveSealedProductIds([23874])).get(23874);
const id = group.get('secret lair drop: calling all hydra heads');
expect(id).toBe('522559');
// A string, not a number: the catalog side is MTGJSON's string, and a
// Number key silently matches nothing (§5.4).
expect(typeof id).toBe('string');
});
it('matches case- and whitespace-insensitively', async () => {
service._setDeps({ httpGet: async () => CSV, redis: null });
const group = (await service.resolveSealedProductIds([23874])).get(23874);
expect(group.get('ugins fate booster pack')).toBe('123456');
});
it('fetches each group exactly once', async () => {
let calls = 0;
service._setDeps({ httpGet: async () => { calls++; return CSV; }, redis: null });
await service.resolveSealedProductIds([23874, 23874]);
expect(calls).toBe(1);
});
it('degrades to an empty map for a group TCGCSV will not serve', async () => {
service._setDeps({ httpGet: async () => { throw new Error('HTTP 404'); }, redis: null });
const out = await service.resolveSealedProductIds([999999]);
// One unreachable group must not fail the whole preview — its rows fall
// to unmatched with a plain reason instead.
expect(out.get(999999).size).toBe(0);
});
});- [ ] Step 3: Run to verify they fail
bash
npx vitest run server/services/tcgcsvSealedService.test.jsExpected: FAIL — module not found.
- [ ] Step 4: Add a text fetch to
tcgcsvGroupService
httpsGetJSON there parses JSON; ProductsAndPrices.csv is text. Add the sibling next to it and export it, rather than writing a second https.get wrapper (§5.8). Keep REQUEST_OPTIONS — TCGCSV blocks unidentified agents with a plaintext body (verified 2026-08-04).
js
function httpsGetText(url, get = https.get) {
return new Promise((resolve, reject) => {
const request = get(url, REQUEST_OPTIONS, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
return httpsGetText(res.headers.location, get).then(resolve, reject);
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`TCGCSV returned HTTP ${res.statusCode}`));
}
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => resolve(body));
}).on('error', reject);
request.setTimeout(REQUEST_TIMEOUT_MS, () => {
request.destroy();
reject(new Error(`TCGCSV request timed out after ${REQUEST_TIMEOUT_MS}ms`));
});
});
}Add httpsGetText to that file's module.exports.
- [ ] Step 5: Implement the resolver
js
/**
* TCGplayer sealed product-name bridge.
*
* A sealed CSV row names a product the way TCGplayer does; our catalog knows it
* by MTGJSON uuid. The two are joined through TCGplayer's own product id, which
* MTGJSON records as identifiers.tcgplayerProductId:
*
* Product Name -> TCGCSV product row -> productId -> uuid
*
* The export's own `TCGplayer Id` column cannot be used: it is SKU-level, not
* product-level. Measured on the reference export — "Secret Lair Drop: Calling
* All Hydra Heads (WPN Exclusive) - Traditional Foil Edition" is TCGCSV
* productId 522559 while the export writes 7488026.
*
* DEMAND-DRIVEN. ProductsAndPrices.csv runs to ~18,764 rows for a group like
* Secret Lair, so it is fetched only for groups that actually contain sealed
* rows — 2 of 319 groups on the reference export.
*
* productId is returned as a STRING. The catalog side is MTGJSON's string and
* the per-store SealedProduct field is a Number; a mismatched type does not
* throw, it matches nothing (§5.4).
*/
'use strict';
const { httpsGetText } = require('./tcgcsvGroupService');
const { getRedisConnection } = require('../config/redis');
const { parseCsvRows } = require('../utils/tcgplayerCsv');
const logger = require('../utils/logger');
const TCGCSV_BASE = 'https://tcgcsv.com/tcgplayer';
const MTG_CATEGORY_ID = 1;
const CACHE_TTL_SECONDS = 86400; // 24h — TCGCSV publishes daily.
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
if (_deps) return _deps;
let redis;
try { redis = getRedisConnection(); } catch { redis = null; }
return { httpGet: httpsGetText, redis };
}
function normalizeName(name) {
return String(name || '').toLowerCase().trim();
}
async function loadGroupProducts(groupId, deps) {
const cacheKey = `tcgcsv:mtg:sealed:v1:${groupId}`;
if (deps.redis) {
try {
const cached = await deps.redis.get(cacheKey);
if (cached) return new Map(JSON.parse(cached));
} catch (err) {
logger.warn('TCGCSV sealed cache read failed; fetching live', { groupId, error: err.message });
}
}
// Built from a groupId resolved through our own set bridge, never from CSV
// text — no user-controlled outbound fetch.
const url = `${TCGCSV_BASE}/${MTG_CATEGORY_ID}/${groupId}/ProductsAndPrices.csv`;
let byName;
try {
const rows = await parseCsvRows(await deps.httpGet(url));
byName = new Map();
for (const row of rows) {
const productId = String(row.productId || '').trim();
const name = normalizeName(row.name);
if (!productId || !name) continue;
// First row wins: ProductsAndPrices carries one row per product, but
// a duplicate name would otherwise resolve to whichever row happened
// to come last.
if (!byName.has(name)) byName.set(name, productId);
}
} catch (err) {
// One unreachable group must not fail the whole preview. Its rows fall
// to unmatched with a plain reason, which the merchant can act on; a
// thrown preview is not.
logger.warn('TCGCSV sealed product list unavailable for group', { groupId, error: err.message });
return new Map();
}
if (deps.redis && byName.size) {
try {
await deps.redis.setex(cacheKey, CACHE_TTL_SECONDS, JSON.stringify([...byName]));
} catch (err) {
logger.warn('TCGCSV sealed cache write failed', { groupId, error: err.message });
}
}
return byName;
}
/**
* @param {number[]} groupIds - TCGCSV group ids, from our own set bridge.
* @returns {Promise<Map<number, Map<string, string>>>} groupId -> (normalized
* product name -> productId as a string). An unreachable group yields an
* empty inner map, never a throw.
*/
async function resolveSealedProductIds(groupIds) {
const deps = getDeps();
const out = new Map();
for (const groupId of new Set(groupIds)) {
out.set(groupId, await loadGroupProducts(groupId, deps));
}
return out;
}
module.exports = { resolveSealedProductIds, normalizeName, _setDeps, _resetDeps };- [ ] Step 6: Run tests
bash
npx vitest run server/services/tcgcsvSealedService.test.js server/services/tcgcsvGroupService.test.jsExpected: PASS, including the existing group-service tests.
- [ ] Step 7: Commit
bash
git add server/services/tcgcsvSealedService.js server/services/tcgcsvSealedService.test.js server/services/tcgcsvGroupService.js
git commit -m "Resolve TCGplayer sealed product names to product ids"Task 4: Match sealed rows during the preview
Files:
- Modify:
server/models/TcgImportLine.js(addmatch.sealedUuid) - Modify:
server/models/TcgImport.js(addcounts.sealedMatched,counts.sealedUnmatched) - Modify:
server/services/tcgImportService.js(runMatch— sealed pass;getDeps— two new deps) - Test:
server/models/TcgImportLine.test.js(§5.2 round-trip) - Test:
server/services/tcgImportService.test.js
Interfaces:
Consumes:
resolveSealedProductIds,normalizeName(Task 3);plugin.findSealedProducts({ setCodes })(server/plugins/mtg/index.js:444) which returns[{ product, setData }]whereproductcarriesuuid,nameandidentifiers.Produces: on each sealed line,
match.sealedUuid(String,nullwhen unmatched) and a merchant-facingmatch.reason; on the import,counts.sealedMatchedandcounts.sealedUnmatched. Matched sealed rows are added todistinctProducts, so the tier estimate includes them.[ ] Step 1: Write the failing model round-trip test (§5.2)
A new sub-document field that is not declared is dropped by strict mode with no error. This test fails if the schema line is deleted.
js
it('round-trips match.sealedUuid so strict mode cannot drop it', async () => {
const line = await TcgImportLine.create({
importId: new mongoose.Types.ObjectId(),
shop: 'test.myshopify.com',
match: { status: 'sealed', sealedUuid: 'b3f1a2c4-0000-4000-8000-000000000001' }
});
const read = await TcgImportLine.findById(line._id).lean();
expect(read.match.sealedUuid).toBe('b3f1a2c4-0000-4000-8000-000000000001');
});- [ ] Step 2: Write the failing matcher tests
js
describe('runMatch sealed pass', () => {
it('joins a sealed row to a catalog uuid through the TCGplayer product id', async () => {
// Catalog side is MTGJSON's STRING id; TCGCSV side is a string too.
// Both are normalized with String() so a Number on either side still
// joins (§5.4) — this is the one trap in PR 4.
const line = await readLine(importId, 'Secret Lair Drop: Calling All Hydra Heads');
expect(line.match.status).toBe('sealed');
expect(line.match.sealedUuid).toBe('uuid-hydra-heads');
expect(line.match.setCode).toBe('SLD');
});
it('joins even when the catalog stores the product id as a Number', async () => {
// findSealedProducts stub returns identifiers.tcgplayerProductId: 522559
const line = await readLine(importId, 'Secret Lair Drop: Calling All Hydra Heads');
expect(line.match.sealedUuid).toBe('uuid-hydra-heads');
});
it('leaves an unmatched sealed row with a plain reason and no uuid', async () => {
const line = await readLine(importId, 'Some Product We Do Not Carry');
expect(line.match.status).toBe('sealed');
expect(line.match.sealedUuid).toBeNull();
expect(line.match.reason).toMatch(/couldn.t match/i);
});
it('never promotes a sealed row to matched, so phase 3 cannot pick it up', async () => {
const matched = await TcgImportLine.find({ importId, 'match.status': 'matched' }).lean();
expect(matched.every((l) => !l.match.sealedUuid)).toBe(true);
});
it('counts matched sealed products toward the tier estimate', async () => {
const doc = await TcgImport.findById(importId).lean();
expect(doc.counts.sealedMatched).toBe(1);
expect(doc.counts.sealedUnmatched).toBe(1);
// One matched card + one matched sealed product = two products to create.
expect(doc.counts.distinctProducts).toBe(2);
});
});- [ ] Step 3: Run to verify they fail
bash
npx vitest run server/models/TcgImportLine.test.js server/services/tcgImportService.test.jsExpected: FAIL — sealedUuid is dropped by strict mode; sealed rows still carry the PR 1 placeholder reason.
- [ ] Step 4: Declare the new fields
In server/models/TcgImportLine.js, inside the match sub-document, after finish:
js
// Sealed only. Null on an unmatched sealed row: match.status stays
// 'sealed' either way so runImport's phase-3 query
// ({'match.status': 'matched'}) can never pull a sealed row into
// syncSetDirect. Phase 4 selects on sealedUuid != null instead.
sealedUuid: StringIn server/models/TcgImport.js, inside counts, beside the existing sealed:
js
// Sub-counts of `sealed`, which stays the total of sealed rows seen so
// totalRows still reconciles across buckets.
sealedMatched: Number,
sealedUnmatched: Number,- [ ] Step 5: Add the two deps
In tcgImportService.js's getDeps() default object, alongside the existing entries:
js
resolveSealedProductIds: require('./tcgcsvSealedService').resolveSealedProductIds,
normalizeSealedName: require('./tcgcsvSealedService').normalizeName,- [ ] Step 6: Implement the sealed pass in
runMatch
Insert after the singles cursor loop's await flushWrites(); and before counts.distinctProducts = distinctProducts.size;, so matched sealed products are counted in the tier estimate:
js
// === Sealed pass ===
// Runs after the singles loop and before the tier verdict: a sealed
// product is a product, and a merchant must not be told they fit a tier
// that these push them past.
//
// Sealed rows keep match.status 'sealed' whether or not they match. The
// uuid is the signal phase 4 selects on. Promoting them to 'matched'
// would feed them to syncSetDirect as if they were cards.
const sealedLines = await deps.TcgImportLine
.find({ importId: importDoc._id, shop, 'match.status': 'sealed' })
.lean();
if (sealedLines.length) {
// Demand-driven: only groups that actually carry sealed rows, so the
// 18,764-row Secret Lair payload is paid for only when needed.
const sealedGroupIds = [...new Set(
sealedLines
.map((line) => (setMapRaw.get((line.raw || {}).setName || '') || {}).groupId)
.filter((id) => id != null)
)];
const sealedSetCodes = [...new Set(
sealedLines
.flatMap((line) => ((setMapRaw.get((line.raw || {}).setName || '') || {}).setCodes) || [])
)];
const productIdsByGroup = sealedGroupIds.length
? await deps.resolveSealedProductIds(sealedGroupIds)
: new Map();
// Catalog side of the join, keyed by STRING product id. MTGJSON
// writes this as a string and the per-store SealedProduct field is a
// Number (models/SealedProduct.js:54); String() on both sides is
// what keeps a type difference from silently matching nothing
// (§5.4). Bounded by the sealed sets in this file, not the catalog.
const catalogByProductId = new Map();
if (sealedSetCodes.length) {
const plugin = deps.getPlugin(game);
if (plugin.supportsSealed) {
for (const { product, setData } of await plugin.findSealedProducts({ setCodes: sealedSetCodes })) {
const rawId = product.identifiers && product.identifiers.tcgplayerProductId;
if (rawId === undefined || rawId === null || rawId === '') continue;
const key = String(rawId).trim();
if (!catalogByProductId.has(key)) {
catalogByProductId.set(key, { uuid: product.uuid, setCode: setData.code });
}
}
}
}
let sealedWrites = [];
for (const line of sealedLines) {
const raw = line.raw || {};
const resolved = setMapRaw.get(raw.setName || '') || null;
const group = resolved && resolved.groupId != null
? productIdsByGroup.get(resolved.groupId)
: null;
const productId = group
? group.get(deps.normalizeSealedName(raw.productName || ''))
: null;
const hit = productId ? catalogByProductId.get(String(productId).trim()) : null;
const match = hit
? { status: 'sealed', reason: null, sealedUuid: hit.uuid, setCode: hit.setCode,
cardUuid: null, collectorNumber: null, rarity: null, finish: null }
: { status: 'sealed', sealedUuid: null, setCode: null,
reason: `We couldn't match this sealed product to our catalog: ${raw.productName || 'unnamed row'}`,
cardUuid: null, collectorNumber: null, rarity: null, finish: null };
if (hit) {
counts.sealedMatched = (counts.sealedMatched || 0) + 1;
counts.units += (line.parsed && line.parsed.quantity) || 0;
distinctProducts.add(`sealed|${hit.uuid}`);
distinctSets.add(hit.setCode);
} else {
counts.sealedUnmatched = (counts.sealedUnmatched || 0) + 1;
if (unmatchedSample.length < UNMATCHED_SAMPLE_SIZE) {
unmatchedSample.push({
setName: raw.setName || '',
productName: raw.productName || '',
number: '',
reason: match.reason
});
}
}
sealedWrites.push({ updateOne: { filter: { _id: line._id }, update: { $set: { match } } } });
if (sealedWrites.length >= MATCH_WRITE_BATCH_SIZE) {
await deps.TcgImportLine.bulkWrite(sealedWrites);
sealedWrites = [];
}
}
if (sealedWrites.length) await deps.TcgImportLine.bulkWrite(sealedWrites);
}Initialise both sub-counts to 0 where counts is built at the top of runMatch, beside matched: 0, unmatched: 0:
js
sealedMatched: 0, sealedUnmatched: 0,sealedLines uses find().lean(), not a cursor, deliberately: sealed rows are 4 of 8,514 on the reference export, and the sealed lane is bounded by how many Unopened rows a merchant stocks. If that assumption ever changes, this becomes the §5.6 shape and must move to a cursor.
- [ ] Step 7: Run tests
bash
npx vitest run server/models/TcgImportLine.test.js server/models/TcgImport.test.js server/services/tcgImportService.test.jsExpected: PASS.
- [ ] Step 8: Commit
bash
git add server/models/TcgImportLine.js server/models/TcgImport.js server/services/tcgImportService.js server/models/TcgImportLine.test.js server/services/tcgImportService.test.js
git commit -m "Match sealed rows in a TCGplayer import preview to catalog products"Task 5: Phase 4 — stock the sealed products
Files:
- Modify:
server/services/tcgImportRunService.js(getDeps+ a new phase 4 after the phase-3 loop) - Test:
server/services/tcgImportRunService.test.js
Interfaces:
Consumes:
quickAddSealedProductwithquantityMode: 'set'(Task 2);match.sealedUuid(Task 4).Produces: per-line
result.actionof'stocked'or'failed'withresult.error;result.productIdset to the Shopify product id;summary.sealedStockedfolded into the import's summary line andprogress.sealedDoneas the checkpoint.[ ] Step 1: Write the failing tests
js
describe('runImport phase 4 (sealed)', () => {
it('stocks a matched sealed line at absolute quantity', async () => {
await runImport({ ...baseArgs });
expect(deps.quickAddSealedProduct).toHaveBeenCalledWith(expect.objectContaining({
game: 'mtg',
uuid: 'uuid-hydra-heads',
quantity: 2,
quantityMode: 'set'
}));
});
it('passes no price in sync mode so catalog pricing applies', async () => {
await runImport({ ...baseArgs, priceMode: 'sync' });
const call = deps.quickAddSealedProduct.mock.calls[0][0];
// undefined, not null or 0: quickAddSealedProduct treats undefined as
// "no opinion" and prices from the catalog the way Browse & Import does.
expect(call.price).toBeUndefined();
});
it('passes the file price in locked mode', async () => {
await runImport({ ...baseArgs, priceMode: 'locked' });
expect(deps.quickAddSealedProduct.mock.calls[0][0].price).toBe(44.21);
});
it('skips sealed lines that never matched', async () => {
await runImport({ ...baseArgs });
const uuids = deps.quickAddSealedProduct.mock.calls.map((c) => c[0].uuid);
expect(uuids).not.toContain(null);
});
it('records a failing sealed line and keeps going', async () => {
deps.quickAddSealedProduct
.mockRejectedValueOnce(new Error('Shopify said no'))
.mockResolvedValueOnce({ created: true, product: { shopifyProductId: 'gid://p/2' }, quantityAdded: 1 });
const out = await runImport({ ...baseArgs });
const failed = await TcgImportLine.findOne({ importId, 'result.action': 'failed' }).lean();
expect(failed.result.error).toMatch(/Shopify said no/);
expect(out.status).toBe('completed');
});
it('does not re-stock sealed lines on a resumed run', async () => {
// progress.sealedDone already set
await runImport({ ...baseArgs });
expect(deps.quickAddSealedProduct).not.toHaveBeenCalled();
});
});- [ ] Step 2: Run to verify they fail
bash
npx vitest run server/services/tcgImportRunService.test.jsExpected: FAIL — quickAddSealedProduct is never called.
- [ ] Step 3: Add the dep
In tcgImportRunService.js's getDeps() default object — the same seam buylistIntakeService uses:
js
quickAddSealedProduct: require('./sealedQuickAddService').quickAddSealedProduct,- [ ] Step 4: Implement phase 4
Insert after the phase-3 for (const setCode of setCodes) loop closes and before the final invalidateShopSyncCaches(shop) and the status: 'completed' update. Add sealedStocked: 0 to the summary object where it is initialised.
js
// === PHASE 4: sealed ===
// Sealed products never went through syncSetDirect — they are not cards
// and have no set collection, no condition variants and no finish. They
// go through the same quickAddSealedProduct a merchant's manual sealed
// quick-add uses, so a barcode/MSRP/metafield rule fixed there stays
// fixed here (§5.8). The only difference is quantityMode: the CSV's
// Total Quantity is the whole on-hand count, so it is set, not added.
if (!(importDoc.progress && importDoc.progress.sealedDone)) {
await TcgImport.updateOne(
{ _id: importId, shop },
{ $set: { 'progress.phase': 'sealed', heartbeatAt: new Date() } }
);
// Bounded by the merchant's Unopened rows (4 on the reference
// export); not a cursor for that reason. See the same note in
// tcgImportService.runMatch.
const sealedLines = await TcgImportLine.find({
importId: importDoc._id, shop,
'match.status': 'sealed',
'match.sealedUuid': { $ne: null },
'result.action': { $ne: 'stocked' }
}).lean();
for (const line of sealedLines) {
const quantity = (line.parsed && line.parsed.quantity) || 0;
try {
const outcome = await deps.quickAddSealedProduct({
shop,
store,
accessToken,
game,
uuid: line.match.sealedUuid,
quantity,
// undefined in 'sync' mode is load-bearing: it means "no
// opinion on retail price", so a new product is priced
// from the catalog and an existing one keeps the
// merchant's price. 'locked' writes the file's price.
price: priceMode === 'locked' ? line.raw.marketplacePrice : undefined,
quantityMode: 'set'
});
await TcgImportLine.updateOne(
{ _id: line._id },
{
$set: {
'result.action': 'stocked',
'result.productId': (outcome.product && outcome.product.shopifyProductId) || null,
'result.variantId': (outcome.product && outcome.product.shopifyVariantId) || null,
'result.error': outcome.quantityWarning || null
}
}
);
summary.sealedStocked++;
if (outcome.created) summary.productsCreated++;
} catch (error) {
// Per line, not per job: three bad sealed rows must not cost
// the merchant the rest of the import.
logger.error('TCGplayer import: sealed line failed', {
shop, importId: String(importId), uuid: line.match.sealedUuid, error: error.message
});
await TcgImportLine.updateOne(
{ _id: line._id },
{ $set: { 'result.action': 'failed', 'result.error': error.message } }
);
summary.failed++;
}
}
// One checkpoint for the whole sealed phase rather than per line:
// the phase is bounded and re-entering it is cheap, and every line
// already carries its own terminal result.action.
await TcgImport.updateOne(
{ _id: importId, shop },
{
$set: {
'progress.sealedDone': true,
'progress.sealedStocked': summary.sealedStocked,
'progress.failed': summary.failed,
heartbeatAt: new Date()
}
}
);
}Declare the two new progress fields in server/models/TcgImport.js's progress sub-document (§5.2 — an undeclared sub-doc field is dropped silently):
js
sealedDone: Boolean,
sealedStocked: Number,Extend the completion summary string so the merchant sees the sealed work:
js
summary: `Stocked ${summary.stocked} cards and ${summary.sealedStocked} sealed products across ${summary.setsDone} sets (${summary.productsCreated} new products, ${summary.failed} failed)`- [ ] Step 5: Add the progress round-trip test (§5.2)
js
it('round-trips progress.sealedDone and progress.sealedStocked', async () => {
const doc = await TcgImport.create({
shop: 'test.myshopify.com', game: 'mtg', status: 'running',
progress: { phase: 'sealed', sealedDone: true, sealedStocked: 4 }
});
const read = await TcgImport.findById(doc._id).lean();
expect(read.progress.sealedDone).toBe(true);
expect(read.progress.sealedStocked).toBe(4);
});- [ ] Step 6: Run tests
bash
npx vitest run server/services/tcgImportRunService.test.js server/models/TcgImport.test.jsExpected: PASS.
- [ ] Step 7: Commit
bash
git add server/services/tcgImportRunService.js server/models/TcgImport.js server/services/tcgImportRunService.test.js server/models/TcgImport.test.js
git commit -m "Stock sealed products from a TCGplayer import"Task 6: Show sealed results in the preview
Files:
- Modify:
client/src/pages/catalog/CatalogImportPage.jsx(the counts grid at lines 236–243 and the caveat copy at line 177) - Test:
client/src/pages/catalog/CatalogImportPage.test.jsx
Interfaces:
Consumes:
report.counts.sealedMatchedandreport.counts.sealedUnmatched(Task 4).report.counts.sealedkeeps its existing meaning — every sealed row seen — sototalRowsstill reconciles.[ ] Step 1: Write the failing test
js
it('reports sealed products separately from cards', async () => {
renderWithReport({ counts: { ...baseCounts, sealed: 3, sealedMatched: 2, sealedUnmatched: 1 } });
expect(await screen.findByText('Sealed products to import')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
});
it('no longer claims sealed rows are skipped', () => {
renderWithReport({ counts: { ...baseCounts, sealed: 3, sealedMatched: 2, sealedUnmatched: 1 } });
expect(screen.queryByText(/Sealed rows aren't imported yet/i)).not.toBeInTheDocument();
});- [ ] Step 2: Run to verify it fails
bash
npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogImportPage.test.jsxExpected: FAIL — the label does not exist and the stale caveat is still rendered.
- [ ] Step 3: Implement
Replace the single sealed row in the counts grid:
jsx
<div><dt>Sealed products to import</dt><dd>{n(report.counts.sealedMatched)}</dd></div>
<div><dt>Sealed rows we couldn't match</dt><dd>{n(report.counts.sealedUnmatched)}</dd></div>At line 177, delete the sentence Sealed rows aren't imported yet. — it is now false. Leave the rest of that caveat intact.
- [ ] Step 4: Run tests and build
bash
npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogImportPage.test.jsx && npm run buildExpected: both PASS.
- [ ] Step 5: Commit
bash
git add client/src/pages/catalog/CatalogImportPage.jsx client/src/pages/catalog/CatalogImportPage.test.jsx
git commit -m "Show how many sealed products a TCGplayer import will bring in"Task 7: Documentation, cleanup, and the full gate
Files:
Modify:
docs/guides/tcgplayer-import.mdDelete:
server/scripts/verify/verifySealedTcgplayerIds.js[ ] Step 1: Update the merchant guide
Find the passage saying sealed rows are not imported and replace it with what now happens:
markdown
### Sealed products
Rows with a condition of `Unopened` are imported as sealed products. We match
them by name against TCGplayer's own product list, so a sealed product we don't
carry in our catalog is reported on the preview rather than guessed at.
Sealed quantities are **set**, not added: the `Total Quantity` column is treated
as your whole on-hand count, so re-running the same file leaves you with the same
number on the shelf rather than double.
Sealed products aren't re-priced automatically the way singles are. If you chose
to keep your own prices, that price stays until you change it in Shopify.- [ ] Step 2: Delete the throwaway verification script
Its facts are now encoded in Task 3's and Task 4's tests, which fail if the join breaks.
bash
git rm server/scripts/verify/verifySealedTcgplayerIds.js- [ ] Step 3: Run the full gate (§7)
bash
npm test && npm run test:client && npm run lint && npm run build && npm run docs:buildExpected: all exit 0. npm run lint must introduce zero new warnings — compare against pre-change output if unsure.
- [ ] Step 4: Read the per-file coverage table by hand
bash
npm run test:coverageThe configured threshold gate is silently inoperative under Vitest 4, so the command exits 0 regardless (§7). Read the table yourself and confirm every file this PR modified reports ≥70% on all four metrics: tcgcsvSealedService.js, tcgcsvGroupService.js, tcgImportService.js, tcgImportRunService.js, sealedQuickAddService.js, TcgImport.js, TcgImportLine.js.
- [ ] Step 5: Run the §5.5 and rule-6 greps on the diff
bash
git diff main --stat && git diff main | grep -nE "\|\| *'mtg'|\?\? *'mtg'|myshopify\.com" || echo "clean"Expected: clean — no identity defaults, no direct Shopify host references outside shopifyAPI.js.
- [ ] Step 6: End-to-end bar (§7, sync-affecting change)
Against the dev store (ufkes-dev-2.myshopify.com, worker running, ngrok up), upload a small export slice containing at least one Unopened row, confirm the import, and then verify in Shopify admin that:
- the sealed product exists and is published,
- its inventory equals the CSV's
Total Quantityexactly (not double), and - re-running the same file leaves the quantity unchanged.
Point 3 is the whole reason quantityMode exists — do not skip it.
- [ ] Step 7: Commit and open the PR
bash
git add docs/guides/tcgplayer-import.md
git commit -m "Document how a TCGplayer import handles sealed products"The PR description must state the §5.1 parity verdict explicitly: mtg implemented; pokemon and riftbound exempt — unverified, no real export file exists, unblocked by one.
Self-Review
Spec coverage. Every PR-4 requirement in the spec maps to a task: the opening verification of identifiers.tcgplayerProductId → Task 1; the name → productId → uuid join → Tasks 3 and 4; demand-driven ProductsAndPrices.csv fetch → Task 3 Step 5; quickAddSealedProduct with an absolute-quantity mode, both behaviours tested → Task 2; phase 4 in the worker → Task 5; the §5.2 round-trip on new sub-document fields → Tasks 4 and 5; parity statement → Global Constraints and Task 7.
Deliberate scope reductions, both stated in the Global Constraints. No sealed price-lock metafield (nothing re-prices sealed products, so it would be unread scaffolding — §5.9). No new Shopify primitive (setInventoryQuantity shipped in PR 2).
Type consistency. resolveSealedProductIds returns Map<number, Map<string, string>> in Task 3 and is consumed that way in Task 4 (productIdsByGroup.get(resolved.groupId) → inner .get(normalizedName) → string). normalizeName is exported by Task 3 and injected as normalizeSealedName in Task 4. match.sealedUuid is written in Task 4 and selected on in Task 5. quantityMode is introduced in Task 2 and passed in Task 5.
Known risk not resolved by this plan. Task 4 matches on the CSV's Product Name against TCGCSV's name. The spec verified the name side against live TCGCSV, but variant-suffix formatting ("- Traditional Foil Edition") is where an exact-match join is most likely to miss. Task 4's unmatched path reports those rows to the merchant by name rather than guessing, which is the correct failure. If the real match rate on the reference export comes back low, that is a follow-up PR on name normalization — not a reason to loosen the join into a fuzzy match.
