Skip to content

TCGplayer Import — PR 2 (Background the Preview, Execute Singles) 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: A merchant who has reviewed their TCGplayer import report presses Run import and their listed cards appear in Shopify — created, conditioned, and stocked to the exact quantities in the file — while the preview that produced the report stops blocking an HTTP request for a minute.

Architecture: Two changes that share one mechanism. The preview's expensive half (per-line catalog matching, the tier check, the price sample) moves out of the request and onto the existing mtg-sync BullMQ queue as job tcg-import-match; the request keeps only parse, classify, set-resolve, and the line-document write, so the CSV never has to travel to the worker — the line documents are the CSV. The execute half rides the same queue as tcg-import-run, builds the managed-product cache once for the whole file, then drives syncService.syncSetDirect per set code through a new internal cardIdentities filter and applies each line's condition variant and absolute quantity.

Tech Stack: Node.js/Express (CommonJS), Mongoose, BullMQ, Zod, React 18 + retroui + Tailwind, Vitest (test files are ESM).

Spec: docs/superpowers/specs/2026-08-04-tcgplayer-inventory-import-design.md — read its "Codebase facts that shape the design", "Execution" and "Phasing" sections before starting. Read also the measured-results banner at the top: plan against an 81% match rate, not 99%. On the reference export that is 2,395 matched of 2,944 stocked rows.

Predecessor: docs/superpowers/plans/2026-08-04-tcgplayer-import-pr1-preview.md (merged). PR 1 shipped parse, match, preview and both models. PR 3 (price lock) and PR 4 (sealed) follow this plan.

Where this plan departs from the spec, and why

The spec was written 2026-08-04. Things have changed in the repo since, and this plan follows the repo, not the spec. Each departure is load-bearing — do not "correct" it back:

  1. No new queue. The spec says "a dedicated import service and queue". Since then buylist-intake shipped (2026-08-17) as a job name on the existing mtg-sync queue, with the rationale written into server/queues/syncQueue.js: it does the same work a sync does, so it must share the sync worker's concurrency and Shopify rate limiter rather than compete with it, and a fourth queue means a new worker, new Redis wiring and a deploy-shape change on both API and worker (CLAUDE.md §5.7). Both TCG import jobs follow that precedent.

  2. progress.setsDone is the resume checkpoint, and per-line result is the double-write guard. buylistIntakeService proved the shape: persist after every line, never batch to the end, because a process death at line 40 of 60 loses 40 records and the next run re-stocks them. Copy that discipline exactly.

  3. The preview is backgrounded by splitting it, not by caching it. The spec said "It needs backgrounding or caching before PR 2 builds on it" without choosing. Caching is the wrong half — the slow part is per-line catalog matching against this file, which no two uploads share. Splitting is chosen because the line documents already persist every CSV row verbatim, so the worker needs no copy of the CSV. This matters concretely: MAX_IMPORT_ROWS is 20,000 and the upload cap is 20MB, but a Mongo document ceiling is 16MB — stashing the raw CSV on the import doc to hand it to the worker would fail on exactly the large files that need backgrounding most.

  4. priceMode: 'locked' is rejected by this PR's confirm route. The spec's phasing puts the merchant's price opt-out and its enforcement in PR 3. Accepting the choice here while nothing honours it would leave the merchant's prices silently overwritten by the next daily price update — the §5.9 shape with a merchant-visible cost. Task 7 rejects it with a plain message and a test; PR 3 deletes that guard.

Global Constraints

Every task's requirements implicitly include this section.

  • Never invent a literal (§5.4). Every field name below was read out of the live source files, not remembered. The identity field differs per game and is verified: ShopifyMTGProductVariant.sourceCardUUID (line 128), ShopifyPokemonProductVariant.sourceCardId (line 120), ShopifyRiftboundProductVariant.sourceCardId (line 114). Never assume one name covers all three.
  • game is never defaulted (§5.5). No || 'mtg', ?? 'mtg', = 'mtg' anywhere in this diff. Note that syncProcessor.js:83 already carries game = 'mtg' in its destructure — that is pre-existing and out of scope; do not add a second one, and do not "fix" it here either (it changes the behaviour of every existing sync job and belongs in its own PR).
  • No if (game === '…') in the sync core (rule 5). The per-game identity field is resolved through a new plugin method, not a conditional.
  • All Shopify calls go through server/services/shopifyAPI.js (rule 6).
  • Server enforces; client decorates (rule 7). Every new guard is a Zod schema plus a server check; the client copy carries a comment naming the server rule.
  • New Mongoose sub-document fields are declared field-by-field with a round-trip test (§5.2). This plan adds fields to both import models; each gets a test that fails if the schema line is deleted.
  • Never materialise an unbounded read (§5.6, second failure mode). The matcher and the runner both read tcg_import_lines through .cursor(), never await Model.find(...) over a whole import. 20,000 line docs is exactly the shape that stalled the worker in LGS-LEDGER-6.
  • Treat inputs as immutable in sync/pricing paths (§5.11). No .splice/.sort/length = 0 on a function parameter.
  • vi.mock of a server module is silently inert under this repo's Vitest config. Use _setDeps / _resetDeps. Every service touched here already exposes them.
  • Server code is CommonJS (require); test files are ESM (import).
  • Coverage ≥70% on all four metrics for every modified file, read from the per-file table by hand.
  • npm run lint clean, zero new warnings. npm run build passes (the client is touched).
  • Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.

Milestones

The plan has a clean merge point. Tasks 1–4 are independently shippable — they make the existing preview stop blocking a request and change nothing about what it reports. Land them as their own PR if the diff is getting long; the repo's convention is chains of small PRs (§3). Tasks 5–11 add the execution.

TaskDeliverable
A1Model + schema shape for staged matching and a persisted report
2buildPreview splits into stageImport (request) + runMatch (worker)
3tcg-import-match rides the sync queue; the upload route returns 202
4The import page polls instead of hanging on one request
B5setInventoryQuantity — the absolute-quantity Shopify primitive
6cardIdentities in the sync core + per-plugin identity field + returned product ids
7POST /tcg-import/:id/confirm with an atomic claim
8tcgImportRunService.runImport — the execute processor
9Run + progress UI, replacing the "coming soon" button
10POST /tcg-import/:id/exact-count
11Merchant guide, parity statement, end-to-end verification

File Structure

FileChangeResponsibility
server/models/TcgImport.jsmodifymatching status, report sub-doc, run bookkeeping (jobId/queuedAt/heartbeatAt/startedAt/completedAt)
server/models/TcgImportLine.jsmodifypending match status
server/schemas/tcgImport.jsmodifypending in the lines filter; confirm schema wired
server/services/tcgImportService.jsmodifysplit buildPreviewstageImport + runMatch
server/services/tcgImportRunService.jscreatethe execute run: cache once, per-set sync, per-line stock
server/services/shopifyAPI.jsmodifysetInventoryQuantity; quantityMode on ensureVariantForConditionAndInventory
server/services/syncService.jsmodifycardIdentities filter; results.syncedProductIds
server/plugins/BaseGamePlugin.js + mtg/ pokemon/ riftbound/modifygetCardIdentityField() — mirrored across all three (§5.1)
server/models/ShopifyMTGProductVariant.js + Pokemon + Riftboundmodifycompound index on the identity field
server/queues/syncQueue.jsmodifyTCG_IMPORT_MATCH_JOB, TCG_IMPORT_RUN_JOB, two add-job helpers
server/queues/processors/syncProcessor.jsmodifytwo thin job-name branches
server/routes/tcgImport.jsmodify202 upload, confirm, exact-count
client/src/utils/api.jsmodifyconfirmTcgImport, requestTcgImportExactCount
client/src/pages/catalog/CatalogImportPage.jsxmodifypolling, run button, progress, results
docs/guides/tcgplayer-import.mdmodifywhat running the import does

tcgImportRunService.js is a separate file rather than more of tcgImportService.js (591 lines already) for the same reason buylistIntakeService.js is separate from the routes: the run's decisions must be testable without a queue.


Milestone A — Background the preview

Task 1: Staged-matching shape in the models

The request half and the worker half need three shapes the models don't have yet: a line that is recorded but not yet matched, an import that is matching, and a place to persist the parts of the report that are currently only returned (unmatchedSample, priceComparison, pricedSampleSize, skippedNoPrice). Once the report arrives asynchronously, GET /tcg-import/:id has to serve those, so they must be stored.

Files:

  • Modify: server/models/TcgImport.js
  • Modify: server/models/TcgImportLine.js
  • Modify: server/schemas/tcgImport.js:22-26
  • Test: server/models/TcgImport.test.js, server/models/TcgImportLine.test.js

Interfaces:

  • Consumes: nothing (first task).
  • Produces:
    • TcgImport.status enum gains 'matching' — set at upload, cleared to 'preview' or 'failed' by the matcher.
    • TcgImport.report{ unmatchedSample: [{setName, productName, number, reason}], priceComparison: [{productName, setCode, condition, theirPrice, ourPrice}], pricedSampleSize: Number, skippedNoPrice: Number }.
    • TcgImport.jobId: String, queuedAt: Date, startedAt: Date, heartbeatAt: Date, completedAt: Date.
    • TcgImportLine.match.status enum gains 'pending'.
    • Used by Tasks 2, 3, 4, 7, 8, 10.

Both report arrays are bounded by construction — unmatchedSample by UNMATCHED_SAMPLE_SIZE and priceComparison by the 50-row sample — so embedding them is safe. That bound is the reason they may live on the parent doc while the lines may not.

  • [ ] Step 1: Write the failing round-trip tests

Append to server/models/TcgImport.test.js:

javascript
describe('staged matching shape', () => {
    it('persists the matching status', () => {
        const doc = new TcgImport({ shop: 's.myshopify.com', game: 'mtg', status: 'matching' });
        expect(doc.validateSync()).toBeUndefined();
        expect(doc.status).toBe('matching');
    });

    it('round-trips the persisted report, which GET /:id serves once matching is async', () => {
        const doc = new TcgImport({
            shop: 's.myshopify.com',
            game: 'mtg',
            status: 'preview',
            report: {
                unmatchedSample: [{ setName: 'Alliances', productName: 'Lodestone Bauble', number: '', reason: 'We do not have Lodestone Bauble in Alliances' }],
                priceComparison: [{ productName: 'Adarkar Wastes', setCode: '10E', condition: 'lp', theirPrice: 5.37, ourPrice: 4.19 }],
                pricedSampleSize: 50,
                skippedNoPrice: 3
            }
        });
        expect(doc.validateSync()).toBeUndefined();
        const obj = doc.toObject();
        // Field-by-field: a sub-doc field missing from the schema is dropped
        // silently on write (§5.2), so assert each one individually.
        expect(obj.report.unmatchedSample[0].setName).toBe('Alliances');
        expect(obj.report.unmatchedSample[0].productName).toBe('Lodestone Bauble');
        expect(obj.report.unmatchedSample[0].number).toBe('');
        expect(obj.report.unmatchedSample[0].reason).toContain('Lodestone Bauble');
        expect(obj.report.priceComparison[0].productName).toBe('Adarkar Wastes');
        expect(obj.report.priceComparison[0].setCode).toBe('10E');
        expect(obj.report.priceComparison[0].condition).toBe('lp');
        expect(obj.report.priceComparison[0].theirPrice).toBe(5.37);
        expect(obj.report.priceComparison[0].ourPrice).toBe(4.19);
        expect(obj.report.pricedSampleSize).toBe(50);
        expect(obj.report.skippedNoPrice).toBe(3);
    });

    it('round-trips the run bookkeeping the claim and stale check read', () => {
        const when = new Date('2026-08-18T12:00:00Z');
        const doc = new TcgImport({
            shop: 's.myshopify.com', game: 'mtg', status: 'running',
            jobId: '4471', queuedAt: when, startedAt: when, heartbeatAt: when, completedAt: when
        });
        expect(doc.validateSync()).toBeUndefined();
        const obj = doc.toObject();
        expect(obj.jobId).toBe('4471');
        expect(obj.queuedAt).toEqual(when);
        expect(obj.startedAt).toEqual(when);
        expect(obj.heartbeatAt).toEqual(when);
        expect(obj.completedAt).toEqual(when);
    });
});

Append to server/models/TcgImportLine.test.js:

javascript
describe('pending match status', () => {
    it('accepts a line recorded before the matcher has seen it', () => {
        const doc = new TcgImportLine({
            importId: new mongoose.Types.ObjectId(),
            shop: 's.myshopify.com',
            raw: { setName: '10th Edition', productName: 'Adarkar Wastes', number: '347', totalQuantity: 1 },
            parsed: { finish: 'nonfoil', condition: 'lp', quantity: 1 },
            match: { status: 'pending' }
        });
        expect(doc.validateSync()).toBeUndefined();
        expect(doc.match.status).toBe('pending');
    });
});

If mongoose is not already imported in TcgImportLine.test.js, add import mongoose from 'mongoose'; at the top.

  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/models/TcgImport.test.js server/models/TcgImportLine.test.js

Expected: FAIL — matching and pending are not in their enums, and report / jobId are dropped by strict mode so the toObject() assertions read undefined.

  • [ ] Step 3: Add the fields to the schemas

In server/models/TcgImport.js, replace the status field and the comment above it:

javascript
    // No 'cancelled': cancellation is out of scope for this arc, and an
    // unreachable status is dead scaffolding (§5.9).
    //
    // 'matching' is the upload's landing state: the request has recorded every
    // CSV row but the per-line catalog match runs on the worker, so the report
    // does not exist yet. It clears to 'preview' (report ready, awaiting
    // confirm) or 'failed'.
    status: {
        type: String,
        required: true,
        enum: ['matching', 'preview', 'queued', 'running', 'completed', 'failed'],
        default: 'matching'
    },

In the same file, after the setMap field and before progress, add:

javascript
    // The parts of the report that are computed but not derivable from the
    // line documents. Persisted because the report is now produced by a
    // worker, so GET /tcg-import/:id has to serve it rather than the upload
    // response carrying it. Both arrays are bounded by construction --
    // unmatchedSample by UNMATCHED_SAMPLE_SIZE, priceComparison by the 50-row
    // sample -- which is why these may embed while the lines may not.
    report: {
        unmatchedSample: [{
            _id: false,
            setName: String,
            productName: String,
            number: String,
            reason: String
        }],
        priceComparison: [{
            _id: false,
            productName: String,
            setCode: String,
            condition: String,
            theirPrice: Number,
            ourPrice: Number
        }],
        // Rows *sampled* for pricing, not rows successfully priced: a card
        // with no snapshot in the retention window is skipped rather than
        // shown at the 999.99 placeholder. priceComparison.length is the
        // priced count.
        pricedSampleSize: Number,
        skippedNoPrice: Number
    },

And after summary / error, add the run bookkeeping:

javascript
    // BullMQ job id of the in-flight match or run, for correlating worker logs.
    jobId: String,
    queuedAt: Date,
    startedAt: Date,
    // Proof of life, refreshed on every per-set checkpoint. The confirm route's
    // claim treats a 'running' import whose heartbeat has gone stale as
    // reclaimable -- same discipline as BuylistOrder.intake.heartbeatAt, and
    // for the same reason: per-line result records mean a reclaim only picks
    // up what never wrote.
    heartbeatAt: Date,
    completedAt: Date,

In server/models/TcgImportLine.js, replace the match.status field:

javascript
        status: {
            type: String,
            required: true,
            // 'pending' = recorded by the upload, not yet matched. Every other
            // value is terminal for the preview. The upload classifies the
            // cheap buckets itself (a zero-quantity row needs no catalog
            // query); only rows that need a catalog lookup land as 'pending'.
            enum: ['pending', 'matched', 'unmatched', 'zero_qty', 'unsupported_line', 'sealed']
        },
  • [ ] Step 4: Widen the lines filter schema

In server/schemas/tcgImport.js, replace tcgImportLinesQuerySchema's status enum:

javascript
const tcgImportLinesQuerySchema = z.object({
    status: z.enum(['pending', 'matched', 'unmatched', 'zero_qty', 'unsupported_line', 'sealed']).optional(),
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce.number().int().min(1).max(200).default(50)
});
  • [ ] Step 5: Run the tests to verify they pass
bash
npx vitest run server/models/TcgImport.test.js server/models/TcgImportLine.test.js server/schemas

Expected: PASS.

  • [ ] Step 6: Commit
bash
git add server/models/TcgImport.js server/models/TcgImport.test.js server/models/TcgImportLine.js server/models/TcgImportLine.test.js server/schemas/tcgImport.js
git commit -m "Record a TCGplayer import's rows before its report is ready"

Task 2: Split the preview into a request half and a worker half

buildPreview currently does everything inline and takes 56.7s on the reference export. Split it at the point where the cost begins: everything up to and including the line write is cheap and stays in the request; the per-line catalog match, the tier check and the price sample move to runMatch, which the worker calls.

Files:

  • Modify: server/services/tcgImportService.js (replace buildPreview / buildPreviewBody)
  • Test: server/services/tcgImportService.test.js

Interfaces:

  • Consumes: TcgImport.status: 'matching', TcgImportLine.match.status: 'pending', TcgImport.report (Task 1).
  • Produces:
    • stageImport({ shop, game, csvText, filename, uploadedBy }): Promise<{ importId, status: 'matching', counts: { totalRows, zeroQty, unsupportedLine, sealed, pendingMatch } }> — used by Task 3's route.
    • runMatch({ shop, importId, accessToken }): Promise<{ importId, status: 'preview'|'failed' }> — used by Task 3's processor.
    • Both exported from server/services/tcgImportService.js. buildPreview is deleted, not kept as a wrapper: leaving it would be a second entry point nothing calls (§5.9).

Why the TCGCSV set-bridge fetch stays in the request: it is a single ~40KB call cached in Redis for 24h, and a set-resolution failure is the one error the merchant can act on immediately ("TCGCSV is unreachable, try again"). Failing it inside a job would turn an instant, honest error into a queued job that fails a minute later. importDoc.setMap then carries the resolved bridge to the worker, which is exactly why PR 1 persisted it.

  • [ ] Step 1: Write the failing tests

Add to server/services/tcgImportService.test.js. These use the existing _setDeps harness in that file — follow its existing beforeEach/afterEach shape for building fake models.

javascript
describe('stageImport', () => {
    it('records every row and leaves only the rows needing a catalog lookup pending', async () => {
        const inserted = [];
        tcgImportService._setDeps({
            ...baseDeps,
            TcgImportLine: { ...fakeLineModel, insertMany: async (docs) => { inserted.push(...docs); } },
            resolveSetCodes: async () => new Map([['10th Edition', { groupId: 1, setCodes: ['10E', 'P10E'] }]])
        });

        const result = await tcgImportService.stageImport({
            shop: 's.myshopify.com', game: 'mtg', csvText: CSV_TWO_STOCKED_ONE_ZERO,
            filename: 'export.csv', uploadedBy: 'brent@example.com'
        });

        expect(result.status).toBe('matching');
        expect(result.counts.totalRows).toBe(3);
        expect(result.counts.zeroQty).toBe(1);
        expect(result.counts.pendingMatch).toBe(2);
        expect(inserted.filter(d => d.match.status === 'pending')).toHaveLength(2);
        expect(inserted.filter(d => d.match.status === 'zero_qty')).toHaveLength(1);
    });

    it('does not touch the catalog: no product model query happens in the request', async () => {
        let catalogQueries = 0;
        tcgImportService._setDeps({
            ...baseDeps,
            getPlugin: () => ({ getProductModel: () => ({ find: async () => { catalogQueries++; return []; } }) }),
            resolveSetCodes: async () => new Map([['10th Edition', { groupId: 1, setCodes: ['10E'] }]])
        });

        await tcgImportService.stageImport({
            shop: 's.myshopify.com', game: 'mtg', csvText: CSV_TWO_STOCKED_ONE_ZERO,
            filename: 'export.csv'
        });

        // The whole point of the split: matching is the expensive half and it
        // must not run here. If this ever goes above zero the request is slow
        // again and the backgrounding is defeated.
        expect(catalogQueries).toBe(0);
    });

    it('rejects a file over MAX_IMPORT_ROWS before writing anything', async () => {
        const created = [];
        tcgImportService._setDeps({ ...baseDeps, TcgImport: { ...fakeImportModel, create: async (d) => { created.push(d); return { _id: 'x', ...d }; } } });
        await expect(tcgImportService.stageImport({
            shop: 's.myshopify.com', game: 'mtg', csvText: csvWithRows(MAX_IMPORT_ROWS + 1), filename: 'big.csv'
        })).rejects.toThrow(/more than/);
        expect(created).toHaveLength(0);
    });
});

describe('runMatch', () => {
    it('matches the pending lines, writes the report and flips the import to preview', async () => {
        const updates = [];
        tcgImportService._setDeps({
            ...baseDeps,
            TcgImport: {
                ...fakeImportModel,
                findOne: async () => ({
                    _id: 'imp1', shop: 's.myshopify.com', game: 'mtg', status: 'matching',
                    setMap: [{ tcgSetName: '10th Edition', groupId: 1, setCodes: ['10E'] }]
                }),
                updateOne: async (filter, update) => { updates.push(update); return { modifiedCount: 1 }; }
            },
            TcgImportLine: fakeLineCursorOver([
                { _id: 'l1', raw: { setName: '10th Edition', productName: 'Adarkar Wastes', number: '347', marketplacePrice: 5.37 }, parsed: { finish: 'nonfoil', condition: 'lp', quantity: 1 }, match: { status: 'pending' } }
            ]),
            checkProductLimit: async () => ({ tier: 'rampUp', limit: 15000, current: 10, allowed: true })
        });

        const result = await tcgImportService.runMatch({ shop: 's.myshopify.com', importId: 'imp1', accessToken: 'tok' });

        expect(result.status).toBe('preview');
        const final = updates[updates.length - 1].$set;
        expect(final.status).toBe('preview');
        expect(final.counts.matched).toBe(1);
        expect(final.tierCheck.allowed).toBe(true);
        expect(final.report.pricedSampleSize).toBe(1);
    });

    it('marks the import failed with the reason when matching throws, instead of leaving it stuck at matching', async () => {
        const updates = [];
        tcgImportService._setDeps({
            ...baseDeps,
            TcgImport: {
                ...fakeImportModel,
                findOne: async () => ({ _id: 'imp1', shop: 's.myshopify.com', game: 'mtg', status: 'matching', setMap: [] }),
                updateOne: async (filter, update) => { updates.push(update); return { modifiedCount: 1 }; }
            },
            TcgImportLine: fakeLineCursorOver([{ _id: 'l1', raw: {}, parsed: {}, match: { status: 'pending' } }]),
            checkProductLimit: async () => { throw new Error('Shopify said no'); }
        });

        const result = await tcgImportService.runMatch({ shop: 's.myshopify.com', importId: 'imp1', accessToken: 'tok' });

        expect(result.status).toBe('failed');
        const final = updates[updates.length - 1].$set;
        expect(final.status).toBe('failed');
        expect(final.error).toContain('Shopify said no');
    });

    it('reads lines through a cursor, never materialising the whole import', async () => {
        // §5.6: a 20,000-line import awaited in one find() is the shape that
        // stalled the worker in LGS-LEDGER-6. Assert the seam, not the volume.
        let usedFindWithoutCursor = false;
        const lineModel = fakeLineCursorOver([]);
        lineModel.find = (...args) => {
            const q = lineModel._realFind(...args);
            // A caller that awaits the query directly (rather than .cursor())
            // is the bug this guards.
            q.then = () => { usedFindWithoutCursor = true; return Promise.resolve([]); };
            return q;
        };
        tcgImportService._setDeps({
            ...baseDeps,
            TcgImport: { ...fakeImportModel, findOne: async () => ({ _id: 'imp1', shop: 's.myshopify.com', game: 'mtg', status: 'matching', setMap: [] }), updateOne: async () => ({}) },
            TcgImportLine: lineModel,
            checkProductLimit: async () => ({ tier: 'free', limit: 500, current: 0, allowed: true })
        });

        await tcgImportService.runMatch({ shop: 's.myshopify.com', importId: 'imp1', accessToken: 'tok' });
        expect(usedFindWithoutCursor).toBe(false);
    });
});

Add the fixtures these need near the top of the test file, beside the existing ones:

javascript
const CSV_HEADER = 'TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low Price With Shipping,TCG Low Price,Total Quantity,Add to Quantity,TCG Marketplace Price,My Store Reserve Quantity,My Store Price,Photo URL';

const CSV_TWO_STOCKED_ONE_ZERO = [
    CSV_HEADER,
    '376073,Magic,10th Edition,Adarkar Wastes,,347,R,Lightly Played,4.63,,,,1,,5.3700,,,',
    '376074,Magic,10th Edition,Ancestral Memories,,71,R,Near Mint,0.50,,,,2,,0.7500,,,',
    '376075,Magic,10th Edition,Angel of Mercy,,1,U,Near Mint,0.10,,,,0,,0.2500,,,'
].join('\n');

function csvWithRows(count) {
    const rows = [CSV_HEADER];
    for (let i = 0; i < count; i++) {
        rows.push(`${400000 + i},Magic,10th Edition,Card ${i},,${i},C,Near Mint,0.10,,,,1,,0.2500,,,`);
    }
    return rows.join('\n');
}

// Minimal cursor-shaped stand-in for TcgImportLine. runMatch must drive this
// through .cursor(); anything that awaits find() directly fails the §5.6 test.
function fakeLineCursorOver(docs) {
    const model = {
        _realFind: () => ({
            lean: () => model._realFind(),
            cursor: () => ({
                async* [Symbol.asyncIterator]() { for (const d of docs) yield d; }
            })
        }),
        bulkWrite: async () => ({ modifiedCount: docs.length }),
        deleteMany: async () => ({ deletedCount: 0 }),
        countDocuments: async () => docs.length
    };
    model.find = (...args) => model._realFind(...args);
    return model;
}
  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/services/tcgImportService.test.js

Expected: FAIL with tcgImportService.stageImport is not a function.

  • [ ] Step 3: Replace buildPreview with stageImport

In server/services/tcgImportService.js, replace the whole buildPreview function with:

javascript
/**
 * The request half of a preview: read the file, record every row, resolve the
 * set bridge, and hand the expensive per-line matching to the worker.
 *
 * Deliberately does no catalog query. Matching 2,944 rows measured 56.7s
 * against a 300s Cloud Run timeout, which is not a request a merchant should
 * hold open -- and the row cap that made it survivable (20,000) is a ceiling,
 * not a fix. Everything here is O(rows) in Mongo writes and one cached HTTP
 * call, so it stays in the hundreds of milliseconds even at the cap.
 *
 * The line documents are how the CSV reaches the worker. Stashing the raw text
 * on the import doc instead would break on exactly the files that need this
 * most: the upload cap is 20MB and a Mongo document may not exceed 16MB.
 */
async function stageImport({ shop, game, csvText, filename, uploadedBy }) {
    const deps = getDeps();
    const insertBatchSize = deps.insertBatchSize || INSERT_BATCH_SIZE;

    const headerLine = String(csvText || '').split(/\r?\n/, 1)[0] || '';
    assertPricingExportHeader(headerLine);

    const rows = await parseCsvRows(csvText);
    if (rows.length > MAX_IMPORT_ROWS) {
        throw new TcgImportError(
            `That file has more than ${MAX_IMPORT_ROWS.toLocaleString()} rows. Split it and upload in parts.`,
            'TOO_MANY_ROWS'
        );
    }

    const classified = rows.map((row) => ({ row, fields: indexRowByLowercaseKey(row), ...classifyRow(row) }));

    const setNames = [...new Set(
        classified.filter((c) => c.status === 'single' || c.status === 'sealed')
            .map((c) => String(c.fields.get('set name') || '').trim())
            .filter(Boolean)
    )];
    // Stays in the request: one ~40KB call, cached in Redis for 24h, and the
    // one failure the merchant can act on immediately. Persisted to setMap so
    // runMatch needs no second fetch.
    const setMapRaw = setNames.length ? await deps.resolveSetCodes(setNames) : new Map();

    // Every upload writes one line doc per CSV row, including zero-qty ones --
    // nothing else removes them, so repeated exploratory re-uploads from a
    // feature open to every store accumulate unbounded garbage. 'matching' is
    // stale for the same reason 'preview' is: both mean the merchant never
    // confirmed. 'queued'/'running'/'completed'/'failed' hold real ongoing or
    // historical work that must survive a fresh upload.
    const staleImports = await deps.TcgImport.find({ shop, status: { $in: ['matching', 'preview'] } }).select('_id').lean();
    if (staleImports.length) {
        const staleIds = staleImports.map((doc) => doc._id);
        await deps.TcgImportLine.deleteMany({ importId: { $in: staleIds } });
        await deps.TcgImport.deleteMany({ _id: { $in: staleIds } });
    }

    const importDoc = await deps.TcgImport.create({
        shop,
        game,
        status: 'matching',
        filename,
        uploadedAt: new Date(),
        uploadedBy,
        setMap: setNames.map((name) => ({
            tcgSetName: name,
            groupId: setMapRaw.get(name) ? setMapRaw.get(name).groupId : null,
            setCodes: setMapRaw.get(name) ? setMapRaw.get(name).setCodes : null
        })),
        progress: { phase: 'matching', setsDone: [] }
    });

    const counts = { totalRows: classified.length, zeroQty: 0, unsupportedLine: 0, sealed: 0, pendingMatch: 0 };

    let batch = [];
    const flush = async () => {
        if (!batch.length) return;
        await deps.TcgImportLine.insertMany(batch);
        batch = [];
    };

    try {
        for (const c of classified) {
            const setName = String(c.fields.get('set name') || '').trim();
            let match;
            if (c.status === 'zero_qty') {
                counts.zeroQty++;
                match = { status: 'zero_qty', reason: c.reason, setCode: null, cardUuid: null, collectorNumber: null, rarity: null, finish: null };
            } else if (c.status === 'unsupported_line') {
                counts.unsupportedLine++;
                match = { status: 'unsupported_line', reason: c.reason, setCode: null, cardUuid: null, collectorNumber: null, rarity: null, finish: null };
            } else if (c.status === 'sealed') {
                counts.sealed++;
                match = {
                    status: 'sealed',
                    reason: 'Sealed product — supported in a later release',
                    setCode: null, cardUuid: null, collectorNumber: null, rarity: null, finish: null
                };
            } else {
                counts.pendingMatch++;
                match = { status: 'pending', reason: null, setCode: null, cardUuid: null, collectorNumber: null, rarity: null, finish: null };
            }

            batch.push({
                importId: importDoc._id,
                shop,
                raw: {
                    tcgId: String(c.fields.get('tcgplayer id') || ''),
                    productLine: String(c.fields.get('product line') || ''),
                    setName,
                    productName: String(c.fields.get('product name') || '').trim(),
                    number: String(c.fields.get('number') || '').trim(),
                    rarity: String(c.fields.get('rarity') || ''),
                    condition: String(c.fields.get('condition') || ''),
                    totalQuantity: parseInt(c.fields.get('total quantity'), 10) || 0,
                    marketplacePrice: toNumber(c.fields.get('tcg marketplace price')),
                    marketPrice: toNumber(c.fields.get('tcg market price'))
                },
                parsed: c.parsed || { finish: null, condition: null, quantity: 0 },
                match,
                result: { productId: null, variantId: null, action: null, error: null }
            });
            if (batch.length >= insertBatchSize) await flush();
        }
        await flush();

        await deps.TcgImport.updateOne({ _id: importDoc._id }, { $set: { counts } });
    } catch (error) {
        // A throw past the create would otherwise leave an import stuck at
        // 'matching' with every line already inserted, and GET /tcg-import/:id
        // serves that happily forever with nothing to clean it up. Best-effort:
        // the original error is what the caller needs to see.
        await Promise.allSettled([
            deps.TcgImportLine.deleteMany({ importId: importDoc._id }),
            deps.TcgImport.deleteOne({ _id: importDoc._id })
        ]);
        throw error;
    }

    return { importId: importDoc._id, status: 'matching', counts };
}
  • [ ] Step 4: Replace buildPreviewBody with runMatch

Replace the whole buildPreviewBody function with:

javascript
/**
 * The worker half of a preview: match every pending line against the catalog,
 * then compute the tier verdict and the price sample, and publish the report.
 *
 * Never throws for a matching failure -- it records the reason on the import
 * and returns, because a job that throws here would be retried by BullMQ
 * against an import already half-matched. The caller (syncProcessor) turns a
 * thrown error into a job failure; a recorded one into a merchant-visible
 * message.
 */
async function runMatch({ shop, importId, accessToken }) {
    const deps = getDeps();

    const importDoc = await deps.TcgImport.findOne({ _id: importId, shop });
    if (!importDoc) return { importId, status: 'failed', error: 'Import not found' };

    const game = importDoc.game;
    // Rebuilt from the persisted bridge, not re-fetched: PR 1 stored setMap so
    // a re-run needs no second TCGCSV call, and so a wrong match can be traced
    // to the group it came from.
    const setMapRaw = new Map(
        (importDoc.setMap || []).map((entry) => [entry.tcgSetName, { groupId: entry.groupId, setCodes: entry.setCodes }])
    );

    const counts = {
        totalRows: importDoc.counts ? importDoc.counts.totalRows : 0,
        zeroQty: importDoc.counts ? importDoc.counts.zeroQty : 0,
        unsupportedLine: importDoc.counts ? importDoc.counts.unsupportedLine : 0,
        sealed: importDoc.counts ? importDoc.counts.sealed : 0,
        matched: 0, unmatched: 0, distinctProducts: 0, distinctSets: 0, units: 0
    };
    const distinctProducts = new Set();
    const distinctSets = new Set();
    const unmatchedSample = [];
    const matchedLines = [];

    try {
        // Cursor, not find(): a 20,000-line import awaited whole is the LGS-LEDGER-6
        // shape -- it stalls the 2GB worker long enough for the Mongo pool's
        // waitQueueTimeoutMS to fire on everything, including the heartbeat write
        // that /health/deep reads (§5.6).
        let writes = [];
        const flushWrites = async () => {
            if (!writes.length) return;
            await deps.TcgImportLine.bulkWrite(writes);
            writes = [];
        };

        const cursor = deps.TcgImportLine
            .find({ importId: importDoc._id, shop, 'match.status': 'pending' })
            .lean()
            .cursor();

        for await (const line of cursor) {
            const setName = line.raw ? line.raw.setName : '';
            const resolved = setMapRaw.get(setName) || null;
            const match = await matchLine({
                setCodes: resolved ? resolved.setCodes : null,
                setName,
                productName: line.raw ? line.raw.productName : '',
                number: line.raw ? line.raw.number : '',
                finish: line.parsed ? line.parsed.finish : null
            }, game, deps);

            if (match.status === 'matched') {
                counts.matched++;
                counts.units += (line.parsed && line.parsed.quantity) || 0;
                distinctProducts.add(`${match.setCode}|${match.cardUuid}`);
                distinctSets.add(match.setCode);
                // Bounded by choosePriceSample's 50; holding the matched lines
                // for sampling is the one place this loop accumulates, so keep
                // only the fields the sample and the comparison table read.
                matchedLines.push({
                    raw: { productName: line.raw.productName, marketplacePrice: line.raw.marketplacePrice },
                    parsed: line.parsed,
                    match
                });
            } else {
                counts.unmatched++;
                if (unmatchedSample.length < UNMATCHED_SAMPLE_SIZE) {
                    unmatchedSample.push({
                        setName,
                        productName: line.raw ? line.raw.productName : '',
                        number: line.raw ? line.raw.number : '',
                        reason: match.reason
                    });
                }
            }

            writes.push({ updateOne: { filter: { _id: line._id }, update: { $set: { match } } } });
            if (writes.length >= MATCH_WRITE_BATCH_SIZE) await flushWrites();
        }
        await flushWrites();

        counts.distinctProducts = distinctProducts.size;
        counts.distinctSets = distinctSets.size;

        const limitResult = await deps.checkProductLimit(shop, accessToken, counts.distinctProducts);
        const needed = (limitResult.current || 0) + counts.distinctProducts;
        const tierCheck = {
            tier: limitResult.tier,
            limit: limitResult.limit,
            current: limitResult.current,
            estimated: counts.distinctProducts,
            allowed: limitResult.allowed,
            // Conservative: cards the store already has are counted again,
            // because knowing otherwise needs the managed-collection cache.
            // POST /tcg-import/:id/exact-count runs that and sets exact = true.
            requiredTier: limitResult.allowed ? null : cheapestTierThatFits(needed),
            exact: false
        };

        const report = await buildPriceReport({ shop, game, matchedLines, deps });

        await deps.TcgImport.updateOne(
            { _id: importDoc._id, shop },
            { $set: { status: 'preview', counts, tierCheck, report, 'progress.phase': 'preview', completedAt: new Date() } }
        );

        return { importId: importDoc._id, status: 'preview' };
    } catch (error) {
        logger.error('TCGplayer import matching failed', { shop, importId: String(importId), error: error.message });
        await deps.TcgImport.updateOne(
            { _id: importDoc._id, shop },
            { $set: { status: 'failed', error: `We could not finish reading that file: ${error.message}`, completedAt: new Date() } }
        );
        return { importId: importDoc._id, status: 'failed' };
    }
}

/**
 * The 50-row price comparison, extracted so runMatch's control flow stays
 * readable. Same two-step recipe as
 * shopifyAPI.ensureVariantForConditionAndInventory: the merchant's price is
 * per condition, so ours must be too -- 2,855 of the reference export's 2,944
 * stocked rows are non-NM, and comparing their LP price against our NM price
 * would overstate us on nearly every row.
 */
async function buildPriceReport({ shop, game, matchedLines, deps }) {
    // getGamePricingConfig is SYNCHRONOUS and takes the Store *document*, not a
    // shop string (pricingConfigService.js:114) -- verified, not assumed.
    const storeDoc = await deps.Store.findOne({ shop }).lean();
    const gameConfig = deps.getGamePricingConfig(storeDoc, game);
    const conditionMultipliers = gameConfig
        && gameConfig.conditionVariants
        && gameConfig.conditionVariants.conditionMultipliers;

    const priceComparison = [];
    const priceSample = choosePriceSample(matchedLines);
    if (priceSample.length) {
        const priceFn = deps.priceFns[game]; // eslint-disable-line security/detect-object-injection -- game is validated against the plugin registry upstream, never arbitrary input
        if (!priceFn) throw new Error(`No price lookup function for game: ${game}`);

        for (const line of priceSample) {
            const prices = await priceFn(line.match.cardUuid, line.match.rarity, [line.match.finish], gameConfig);
            // A card with no snapshot in the retention window returns a 999.99
            // placeholder plus _isPreSale (priceLookupService.js:774-786). Skip
            // on the flag first -- the numeric guard alone lets a fabricated
            // ~$950 "our price" straight into the merchant-facing table.
            if (prices && prices._isPreSale) continue;
            const nmPrice = prices ? prices[line.match.finish] : null;
            if (typeof nmPrice !== 'number' || nmPrice <= 0) continue;

            priceComparison.push({
                productName: line.raw.productName,
                setCode: line.match.setCode,
                condition: line.parsed.condition,
                theirPrice: line.raw.marketplacePrice,
                ourPrice: deps.calculateConditionPrice(nmPrice, line.parsed.condition, conditionMultipliers)
            });
        }
    }

    return {
        unmatchedSample: [],
        priceComparison,
        // Rows sampled, not rows priced: an empty priceComparison is otherwise
        // indistinguishable from "nothing to compare".
        pricedSampleSize: priceSample.length,
        skippedNoPrice: priceSample.length - priceComparison.length
    };
}

Note the unmatchedSample: [] above — buildPriceReport does not own it. Fix that by merging in runMatch: change the report line in runMatch to

javascript
        const report = { ...(await buildPriceReport({ shop, game, matchedLines, deps })), unmatchedSample };
  • [ ] Step 5: Add the write-batch constant and update the exports

Near INSERT_BATCH_SIZE at the top of the file, add:

javascript
// Match results are flushed in batches for the same reason lines are inserted
// in batches: one bulkWrite per row would be 2,944 round trips on the
// reference export.
const MATCH_WRITE_BATCH_SIZE = 500;

Replace the module export line:

javascript
module.exports = { stageImport, runMatch, matchLine, choosePriceSample, _setDeps, _resetDeps };
  • [ ] Step 6: Run the tests to verify they pass
bash
npx vitest run server/services/tcgImportService.test.js

Expected: PASS. Existing buildPreview tests in that file will fail — rewrite each to call stageImport then runMatch in sequence, which is what the route and processor now do. Do not keep a buildPreview shim to make them pass.

  • [ ] Step 7: Commit
bash
git add server/services/tcgImportService.js server/services/tcgImportService.test.js
git commit -m "Match a TCGplayer export's rows in the background instead of holding the upload open"

Task 3: Ride the sync queue and return 202 from the upload

Files:

  • Modify: server/queues/syncQueue.js
  • Modify: server/queues/processors/syncProcessor.js:20 (import) and :76-82 (branch)
  • Modify: server/routes/tcgImport.js (handlePreview)
  • Test: server/routes/tcgImport.test.js, server/queues/processors/syncProcessor.test.js (create if absent)

Interfaces:

  • Consumes: stageImport, runMatch (Task 2).

  • Produces:

    • syncQueue.TCG_IMPORT_MATCH_JOB = 'tcg-import-match'
    • syncQueue.addTcgImportMatchJob({ shop, importId, accessToken }, options?): Promise<Job>
    • POST /api/tcg-import/preview202 { importId, status: 'matching', counts }
    • Used by Tasks 4, 7, 8.
  • [ ] Step 1: Write the failing route test

Add to server/routes/tcgImport.test.js:

javascript
describe('POST /tcg-import/preview (backgrounded)', () => {
    it('returns 202 with the staged counts and queues the match job', async () => {
        const queued = [];
        route._setDeps({
            stageImport: async () => ({ importId: 'imp1', status: 'matching', counts: { totalRows: 3, zeroQty: 1, unsupportedLine: 0, sealed: 0, pendingMatch: 2 } }),
            addTcgImportMatchJob: async (data) => { queued.push(data); return { id: '77' }; },
            TcgImport: fakeImportModel,
            TcgImportLine: fakeLineModel
        });

        const res = await request(app)
            .post('/api/tcg-import/preview?game=mtg')
            .set('Content-Type', 'text/csv')
            .send(CSV_TWO_STOCKED_ONE_ZERO);

        expect(res.status).toBe(202);
        expect(res.body.importId).toBe('imp1');
        expect(res.body.status).toBe('matching');
        expect(res.body.counts.pendingMatch).toBe(2);
        expect(queued).toHaveLength(1);
        expect(queued[0].importId).toBe('imp1');
    });

    it('marks the import failed when the job cannot be queued, so it does not sit at matching forever', async () => {
        const updates = [];
        route._setDeps({
            stageImport: async () => ({ importId: 'imp1', status: 'matching', counts: {} }),
            addTcgImportMatchJob: async () => { throw new Error('NOAUTH'); },
            TcgImport: { ...fakeImportModel, updateOne: async (f, u) => { updates.push(u); return {}; } },
            TcgImportLine: fakeLineModel
        });

        const res = await request(app)
            .post('/api/tcg-import/preview?game=mtg')
            .set('Content-Type', 'text/csv')
            .send(CSV_TWO_STOCKED_ONE_ZERO);

        expect(res.status).toBe(500);
        expect(updates[0].$set.status).toBe('failed');
    });
});
  • [ ] Step 2: Run it to verify it fails
bash
npx vitest run server/routes/tcgImport.test.js

Expected: FAIL — the route still returns 200 with the full report.

  • [ ] Step 3: Add the job name and enqueue helper

In server/queues/syncQueue.js, after the addBuylistIntakeJob block, add:

javascript
// TCGplayer import job names. Same reasoning as BUYLIST_INTAKE_JOB above: both
// do the work a sync does -- reading the catalog, creating Shopify products,
// moving stock -- so they belong on the sync worker's concurrency and rate
// limiter rather than competing with it from a fourth queue, which would mean
// new Redis wiring and a deploy-shape change on both API and worker (§5.7).
const TCG_IMPORT_MATCH_JOB = 'tcg-import-match';

/**
 * Queue the catalog-matching pass for an uploaded TCGplayer export.
 * @param {object} jobData - { shop, importId, accessToken (encrypted) }
 * @returns {Promise<Job>} The created job
 */
const addTcgImportMatchJob = async (jobData, options = {}) => {
    const q = getQueue();
    const job = await q.add(TCG_IMPORT_MATCH_JOB, jobData, {
        // Override the queue's attempts: 3 + 60s backoff. runMatch records its
        // own failure on the import and returns rather than throwing, so a
        // BullMQ retry would only ever re-run a pass that already reported why
        // it stopped -- and re-matching a half-matched import from the top is
        // wasted Shopify and Mongo work, not a recovery. The merchant re-runs
        // by uploading again.
        attempts: 1,
        ...options
    });

    logger.info('TCGplayer import match job added to queue', {
        jobId: job.id,
        shop: jobData.shop,
        importId: jobData.importId
    });

    return job;
};

Add both to the module.exports object: TCG_IMPORT_MATCH_JOB, addTcgImportMatchJob.

  • [ ] Step 4: Branch the processor

In server/queues/processors/syncProcessor.js, extend the import on line 20:

javascript
const { addSyncJob, BUYLIST_INTAKE_JOB, TCG_IMPORT_MATCH_JOB } = require('../syncQueue');

Add this processor beside buylistIntakeProcessor:

javascript
/**
 * TCGplayer import matching pass. Thin by design -- every decision lives in
 * tcgImportService.runMatch, which is testable without a queue.
 *
 * runMatch records its own failures on the import rather than throwing, so
 * this returns a result either way and never triggers a BullMQ retry.
 */
const tcgImportMatchProcessor = async (job) => {
    const { shop, importId, accessToken: encryptedToken } = job.data;
    logger.info('Processing TCGplayer import match', { jobId: job.id, shop, importId });

    const { runMatch } = require('../../services/tcgImportService');
    const store = await Store.findOne({ shop });
    if (!store) throw new Error(`Store ${shop} not found`);

    const result = await runMatch({
        shop,
        // Store record wins over the payload token, which was captured at
        // enqueue time -- see utils/storeAccessToken.js.
        accessToken: resolveStoreAccessToken(store, encryptedToken),
        importId
    });

    return { ok: result.status === 'preview', status: result.status };
};

And extend the branch at the top of syncProcessor:

javascript
    // Buylist inventory intake and the TCGplayer import share this queue (see
    // syncQueue's job-name constants) but not this pipeline: neither has a
    // SyncJob record, phase/progress model or resume semantics. Their own
    // per-line records make them re-runnable instead.
    if (job.name === BUYLIST_INTAKE_JOB) {
        return buylistIntakeProcessor(job);
    }
    if (job.name === TCG_IMPORT_MATCH_JOB) {
        return tcgImportMatchProcessor(job);
    }
  • [ ] Step 5: Return 202 from the upload route

In server/routes/tcgImport.js, extend getDeps():

javascript
function getDeps() {
    if (_deps) return _deps;
    const { addTcgImportMatchJob } = require('../queues/syncQueue');
    return {
        stageImport: defaultService.stageImport,
        addTcgImportMatchJob,
        TcgImport,
        TcgImportLine
    };
}

Replace the body of handlePreview from the try onward:

javascript
    const deps = getDeps();
    let staged;
    try {
        staged = await deps.stageImport({
            shop: req.shop,
            game,
            csvText,
            filename: req.get('X-Filename') || 'tcgplayer-export.csv',
            uploadedBy: req.user ? req.user.email : undefined
        });
    } catch (error) {
        if (error.name === 'TcgImportError') {
            return res.status(400).json({ error: error.message, code: error.code });
        }
        logger.error('TCGplayer import staging failed', { shop: req.shop, error: error.message });
        return res.status(500).json({ error: 'We could not read that file. Try the upload again.' });
    }

    try {
        const { encryptToken } = require('../utils/crypto');
        const job = await deps.addTcgImportMatchJob({
            shop: req.shop,
            importId: String(staged.importId),
            accessToken: encryptToken(req.accessToken)
        });
        await deps.TcgImport.updateOne({ _id: staged.importId, shop: req.shop }, { $set: { jobId: String(job.id), queuedAt: new Date() } });
    } catch (error) {
        // Redis down, or the enqueue timeout at the createQueueModule choke
        // point (§5.13) fired. Without this the import sits at 'matching' with
        // nothing on its way to move it, and the client polls forever.
        logger.error('Failed to queue TCGplayer import match', { shop: req.shop, importId: String(staged.importId), error: error.message });
        await deps.TcgImport.updateOne(
            { _id: staged.importId, shop: req.shop },
            { $set: { status: 'failed', error: 'We could not start reading that file. Try the upload again.' } }
        );
        return res.status(500).json({ error: 'We could not start reading that file. Try the upload again.' });
    }

    // 202, not 200: the report does not exist yet. The client polls
    // GET /tcg-import/:id until status leaves 'matching'.
    return res.status(202).json(staged);
  • [ ] Step 6: Run the tests to verify they pass
bash
npx vitest run server/routes/tcgImport.test.js server/queues

Expected: PASS.

  • [ ] Step 7: Commit
bash
git add server/queues/syncQueue.js server/queues/processors/syncProcessor.js server/routes/tcgImport.js server/routes/tcgImport.test.js
git commit -m "Hand a TCGplayer upload straight back and read the file on the worker"

Task 4: Poll for the report instead of hanging on one request

Files:

  • Modify: client/src/pages/catalog/CatalogImportPage.jsx
  • Test: client/src/pages/catalog/CatalogImportPage.test.jsx (create if absent)

Interfaces:

  • Consumes: 202 { importId, status, counts } from previewTcgImport; getTcgImport(id) (already in client/src/utils/api.js:342) now returns the persisted doc with counts, tierCheck and report.
  • Produces: a page that renders from the import document shape rather than the old flat report. Used by Task 9.

The render code currently reads report.counts, report.unmatchedSample, report.priceComparison, report.pricedSampleSize, report.skippedNoPrice. Under the new shape those become doc.counts and doc.report.*. Update every read; do not add a normalising adapter that keeps both shapes alive.

  • [ ] Step 1: Write the failing test

Create client/src/pages/catalog/CatalogImportPage.test.jsx:

javascript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import CatalogImportPage from './CatalogImportPage';
import * as api from '../../utils/api';

function renderPage() {
    return render(
        <MemoryRouter initialEntries={['/catalog/mtg/import']}>
            <Routes>
                <Route path="/catalog/:game/import" element={<CatalogImportPage />} />
            </Routes>
        </MemoryRouter>
    );
}

describe('CatalogImportPage polling', () => {
    beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); });
    afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); });

    it('shows a reading state, then the report once matching completes', async () => {
        vi.spyOn(api, 'previewTcgImport').mockResolvedValue({ importId: 'imp1', status: 'matching', counts: { totalRows: 3, pendingMatch: 2 } });
        const get = vi.spyOn(api, 'getTcgImport')
            .mockResolvedValueOnce({ _id: 'imp1', status: 'matching' })
            .mockResolvedValue({
                _id: 'imp1', status: 'preview',
                counts: { totalRows: 3, zeroQty: 1, matched: 2, unmatched: 0, sealed: 0, unsupportedLine: 0, distinctProducts: 2, distinctSets: 1, units: 3 },
                tierCheck: { tier: 'rampUp', limit: 15000, current: 10, estimated: 2, allowed: true, exact: false },
                report: { unmatchedSample: [], priceComparison: [], pricedSampleSize: 0, skippedNoPrice: 0 }
            });

        renderPage();
        const file = new File(['TCGplayer Id,x'], 'export.csv', { type: 'text/csv' });
        const input = screen.getByLabelText(/choose a file|upload/i);
        await userEventUpload(input, file);

        await waitFor(() => expect(screen.getByText(/reading your file/i)).toBeInTheDocument());
        await vi.advanceTimersByTimeAsync(4000);
        await waitFor(() => expect(screen.getByText(/2 rows matched/i)).toBeInTheDocument());
        expect(get).toHaveBeenCalled();
    });

    it('stops polling and shows the reason when matching fails', async () => {
        vi.spyOn(api, 'previewTcgImport').mockResolvedValue({ importId: 'imp1', status: 'matching', counts: {} });
        const get = vi.spyOn(api, 'getTcgImport').mockResolvedValue({ _id: 'imp1', status: 'failed', error: 'We could not finish reading that file: TCGCSV timed out' });

        renderPage();
        const file = new File(['TCGplayer Id,x'], 'export.csv', { type: 'text/csv' });
        await userEventUpload(screen.getByLabelText(/choose a file|upload/i), file);

        await vi.advanceTimersByTimeAsync(4000);
        await waitFor(() => expect(screen.getByText(/TCGCSV timed out/i)).toBeInTheDocument());
        const callsAfterFailure = get.mock.calls.length;
        await vi.advanceTimersByTimeAsync(10000);
        expect(get.mock.calls.length).toBe(callsAfterFailure);
    });
});

Add the upload helper at the top of the file (the page reads the file with FileReader, so the test must let that resolve):

javascript
import userEvent from '@testing-library/user-event';

async function userEventUpload(input, file) {
    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
    await user.upload(input, file);
}
  • [ ] Step 2: Run it to verify it fails
bash
npm run test:client -- CatalogImportPage

Expected: FAIL — the page has no polling and renders nothing for a matching response.

  • [ ] Step 3: Add polling to the page

In client/src/pages/catalog/CatalogImportPage.jsx, add useEffect, useRef to the React import and getTcgImport to the api import. Replace the state block:

javascript
  const [importDoc, setImportDoc] = useState(null);
  const [staged, setStaged] = useState(null);
  const [error, setError] = useState(null);
  const [busy, setBusy] = useState(false);
  const pollRef = useRef(null);

Replace the reader.onload body:

javascript
    reader.onload = async () => {
      try {
        // 202 + { importId, status: 'matching' }: the report is built on the
        // worker, so this only tells us the file was accepted.
        setStaged(await previewTcgImport(game, String(reader.result), file.name));
      } catch (err) {
        setError(err?.response?.data?.error || 'We could not read that file. Try the upload again.');
        setBusy(false);
      }
    };

Add the polling effect after the handler:

javascript
  // Poll while the worker matches. 3s: matching the reference export measured
  // ~57s, so this is ~19 requests for a large file and near-instant feedback
  // for a small one.
  useEffect(() => {
    if (!staged?.importId) return undefined;

    let cancelled = false;
    const tick = async () => {
      try {
        const doc = await getTcgImport(staged.importId);
        if (cancelled) return;
        setImportDoc(doc);
        if (doc.status !== 'matching') {
          setBusy(false);
          if (doc.status === 'failed') setError(doc.error || 'We could not finish reading that file.');
          clearInterval(pollRef.current);
        }
      } catch (err) {
        if (cancelled) return;
        setError(err?.response?.data?.error || 'We lost track of that import. Try the upload again.');
        setBusy(false);
        clearInterval(pollRef.current);
      }
    };

    tick();
    pollRef.current = setInterval(tick, 3000);
    return () => { cancelled = true; clearInterval(pollRef.current); };
  }, [staged?.importId]);
  • [ ] Step 4: Render the reading state and re-point the report reads

Insert above the existing report block:

javascript
      {busy && importDoc?.status !== 'preview' && (
        <Card className="p-4">
          <Text as="h3" className="font-bold mb-1">Reading your file…</Text>
          <Text as="p" className="text-sm">
            We&apos;re matching {n(staged?.counts?.pendingMatch)} rows against our catalog. A large
            export takes about a minute — you can leave this page and come back.
          </Text>
        </Card>
      )}

Then replace every report. read in the existing JSX:

oldnew
report && (the report block guard)importDoc?.status === 'preview' &&
report.countsimportDoc.counts
report.tierCheckimportDoc.tierCheck
report.unmatchedSampleimportDoc.report?.unmatchedSample
report.priceComparisonimportDoc.report?.priceComparison
report.pricedSampleSizeimportDoc.report?.pricedSampleSize
report.skippedNoPriceimportDoc.report?.skippedNoPrice

and update the three derived counters near the top of the component:

javascript
  const sampledCount = typeof importDoc?.report?.pricedSampleSize === 'number' ? importDoc.report.pricedSampleSize : 0;
  const skippedCount = typeof importDoc?.report?.skippedNoPrice === 'number' ? importDoc.report.skippedNoPrice : 0;
  const pricedCount = importDoc?.report?.priceComparison?.length || 0;
  • [ ] Step 5: Run the tests to verify they pass
bash
npm run test:client -- CatalogImportPage
npm run build

Expected: PASS, build exits 0.

  • [ ] Step 6: Commit
bash
git add client/src/pages/catalog/CatalogImportPage.jsx client/src/pages/catalog/CatalogImportPage.test.jsx
git commit -m "Show upload progress while we read a TCGplayer export instead of freezing the page"

Milestone A is complete and shippable here. Run npm test && npm run lint && npm run build before moving on.


Milestone B — Execute singles

Task 5: The absolute-quantity Shopify primitive

Inventory writes in this repo are delta-only (addInventoryQuantityinventoryAdjustQuantities). The import's inventory decision is absolute set to Total Quantity, which makes a re-import idempotent: uploading the same export twice leaves the same stock, not double.

Files:

  • Modify: server/services/shopifyAPI.js (after addInventoryQuantity, ~line 1582)
  • Test: server/services/shopifyAPI.test.js

Interfaces:

  • Consumes: nothing new.

  • Produces:

    • shopifyAPI.setInventoryQuantity(inventoryItemId, locationId, quantity): Promise<void>
    • ensureVariantForConditionAndInventory({ …, quantityMode }) where quantityMode is 'add' (default, unchanged for every existing caller) or 'set'.
    • Used by Task 8.
  • [ ] Step 1: Write the failing tests

Add to server/services/shopifyAPI.test.js:

javascript
describe('setInventoryQuantity', () => {
    it('sets an absolute quantity and ignores the compare quantity', async () => {
        const calls = [];
        const api = makeApi({ graphQL: async (q, v) => { calls.push({ q, v }); return { inventorySetQuantities: { userErrors: [] } }; } });

        await api.setInventoryQuantity('gid://shopify/InventoryItem/1', 'gid://shopify/Location/2', 4);

        expect(calls[0].q).toContain('inventorySetQuantities');
        expect(calls[0].v.input.name).toBe('available');
        // Without this the mutation fails whenever another process touched
        // stock since we last read it, which for an import is always possible.
        expect(calls[0].v.input.ignoreCompareQuantity).toBe(true);
        expect(calls[0].v.input.quantities).toEqual([
            { quantity: 4, inventoryItemId: 'gid://shopify/InventoryItem/1', locationId: 'gid://shopify/Location/2' }
        ]);
    });

    it('activates the item at the location when it is not stocked there yet', async () => {
        const seen = [];
        const api = makeApi({
            graphQL: async (q) => {
                seen.push(q);
                if (q.includes('inventorySetQuantities')) {
                    return { inventorySetQuantities: { userErrors: [{ field: 'inventoryItemId', message: 'Item is not stocked at the location' }] } };
                }
                return { inventoryActivate: { inventoryLevel: { id: 'gid://shopify/InventoryLevel/9' }, userErrors: [] } };
            }
        });

        await api.setInventoryQuantity('gid://shopify/InventoryItem/1', 'gid://shopify/Location/2', 4);

        expect(seen.some(q => q.includes('inventoryActivate'))).toBe(true);
    });

    it('throws on any other user error rather than silently reporting success', async () => {
        const api = makeApi({ graphQL: async () => ({ inventorySetQuantities: { userErrors: [{ field: 'quantity', message: 'must be positive' }] } }) });
        await expect(api.setInventoryQuantity('gid://x/1', 'gid://y/2', -1)).rejects.toThrow(/must be positive/);
    });
});

describe('ensureVariantForConditionAndInventory quantityMode', () => {
    it('adds by default, preserving the catalog singles-page behaviour', async () => {
        const api = makeApiForVariantEnsure();
        await api.ensureVariantForConditionAndInventory({ ...baseVariantArgs, quantity: 3 });
        expect(api._inventoryCalls).toEqual([{ mode: 'add', quantity: 3 }]);
    });

    it('sets absolutely when asked, so a re-import is idempotent', async () => {
        const api = makeApiForVariantEnsure();
        await api.ensureVariantForConditionAndInventory({ ...baseVariantArgs, quantity: 3, quantityMode: 'set' });
        expect(api._inventoryCalls).toEqual([{ mode: 'set', quantity: 3 }]);
    });
});

makeApiForVariantEnsure should stub getProductWithVariantsGraphQL, addInventoryQuantity and setInventoryQuantity and record which was called — follow whatever stubbing helper shopifyAPI.test.js already uses for this method.

  • [ ] Step 2: Run them to verify they fail
bash
npx vitest run server/services/shopifyAPI.test.js -t 'setInventoryQuantity'

Expected: FAIL — api.setInventoryQuantity is not a function.

  • [ ] Step 3: Add the primitive

In server/services/shopifyAPI.js, directly after addInventoryQuantity:

javascript
    /**
     * Set an inventory item's available quantity at a location to an absolute
     * value, activating it there first if it isn't stocked yet.
     *
     * The TCGplayer import's inventory decision is absolute-set to the file's
     * Total Quantity, which is what makes a re-import idempotent -- uploading
     * the same export twice leaves the same stock rather than double. The
     * accepted trade-off is that a Shopify-only sale between exports is
     * reverted by the next import.
     *
     * ignoreCompareQuantity: true because we are not reconciling against a
     * quantity we read -- we are asserting the merchant's own count. Without
     * it the mutation fails whenever anything touched stock in between, which
     * during a multi-minute import is routine.
     * @param {string} inventoryItemId - Full GID format
     * @param {string} locationId - Full GID format
     * @param {number} quantity - Absolute available quantity to set (>= 0)
     */
    async setInventoryQuantity(inventoryItemId, locationId, quantity) {
        const mutation = `
            mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) {
                inventorySetQuantities(input: $input) {
                    userErrors {
                        field
                        message
                    }
                }
            }
        `;

        const input = {
            reason: 'correction',
            name: 'available',
            ignoreCompareQuantity: true,
            quantities: [{ quantity, inventoryItemId, locationId }]
        };

        const data = await this.graphQL(mutation, { input });
        const userErrors = data.inventorySetQuantities.userErrors || [];
        if (userErrors.length === 0) return;

        // Same activation gap addInventoryQuantity handles: a brand-new variant
        // has no InventoryLevel record at the location at all.
        if (userErrors.some(e => /not stocked at the location/i.test(e.message))) {
            await this.activateInventoryAtLocation(inventoryItemId, locationId, quantity);
            return;
        }

        const errors = userErrors.map(e => `${e.field}: ${e.message}`).join(', ');
        throw new Error(`Failed to set inventory: ${errors}`);
    }
  • [ ] Step 4: Thread quantityMode through the variant helper

Change the signature of ensureVariantForConditionAndInventory (line 1604) to add quantityMode = 'add':

javascript
    async ensureVariantForConditionAndInventory({ productId, finish, condition, quantity, nmPrice, sku, conditionMultipliers, rarity, pricingConfig, barcode, uniqueKey, gameCode, quantityMode = 'add' }) {

Add to its JSDoc:

javascript
     * @param {'add'|'set'} [params.quantityMode='add'] - 'add' delta-adjusts (the
     *   catalog singles-page shortcut, which means "add N more"); 'set' writes an
     *   absolute quantity (the TCGplayer import, which means "the merchant has
     *   exactly N"). Defaults to 'add' so every existing caller is unchanged.

Then find the inventory call inside that method (the addInventoryQuantity(...) line) and replace it with:

javascript
            if (quantityMode === 'set') {
                await this.setInventoryQuantity(inventoryItemId, locationId, quantity);
            } else {
                await this.addInventoryQuantity(inventoryItemId, locationId, quantity);
            }

Note: addInventoryQuantity is currently guarded by a quantity >= 1 style check in that method. An absolute set of 0 is meaningful (the merchant sold out) while an add of 0 is a no-op, so widen that guard to quantityMode === 'set' ? quantity >= 0 : quantity >= 1. Read the existing guard before editing and preserve its surrounding logic.

  • [ ] Step 5: Run the tests to verify they pass
bash
npx vitest run server/services/shopifyAPI.test.js

Expected: PASS, including every pre-existing ensureVariantForConditionAndInventory test (the default is unchanged).

  • [ ] Step 6: Commit
bash
git add server/services/shopifyAPI.js server/services/shopifyAPI.test.js
git commit -m "Set a card's stock to the exact count an import declares instead of adding to it"

Task 6: cardIdentities in the sync core

The import must create only the cards in the merchant's file, not every card in each of the 333 sets those cards span. It also needs one managed-product cache build for the whole file rather than one per set. syncSetDirect already supports both shapes for a single card (cardNumber); this generalises that to a list, and returns which Shopify product each identity landed on.

Files:

  • Modify: server/plugins/BaseGamePlugin.js (SYNC INTERFACE, ~line 218)
  • Modify: server/plugins/mtg/index.js, server/plugins/pokemon/index.js, server/plugins/riftbound/index.js
  • Modify: server/models/ShopifyMTGProductVariant.js, ShopifyPokemonProductVariant.js, ShopifyRiftboundProductVariant.js
  • Modify: server/services/syncService.js:62-68 (buildSyncProductQuery), :437 (destructure), :501 (query), :696 + :808 (the _persistSyncMarkers call sites), :555 (results), countSyncableProducts
  • Test: server/services/syncService.test.js, server/plugins/*/index.test.js

Interfaces:

  • Consumes: nothing new.
  • Produces:
    • plugin.getCardIdentityField(): string'sourceCardUUID' (mtg) or 'sourceCardId' (pokemon, riftbound).
    • buildSyncProductQuery(sourceSetFilter, { cardNumber, rarities, cardIdentities, identityField })
    • syncSetDirect(setCode, { …, cardIdentities }) — an array of identity strings; when present the sync reads only those documents.
    • results.syncedProductIds: Array<{ identity: string, shopifyProductId: string, action: 'create'|'update' }> on syncSetDirect's return value.
    • Used by Task 8.

Why identity, not collector number. match.cardUuid is what matchLine recorded (doc.sourceCardUUID || doc.sourceCardId) and it is the only key that survives the collision case PR 1 exists to handle: a TCGplayer group covers a set and its promos, and the same collector number occurs in both (measured: MB2 #27 is Displacer Kitten, SIS #27 is Bloodline Keeper). Filtering on collector number would re-admit exactly the ambiguity the matcher resolved.

Why results.syncedProductIds and not the catalog document. _persistSyncMarkers writes shopifyProductId onto the shared, global catalog collection (shopify_mtg_products_variants carries no shop field — see its own doc comment at syncService.js:202). Reading it back to find this store's product would return whichever store synced last. The map has to come out of the run that created it.

  • [ ] Step 1: Write the failing plugin-parity tests

Add to each of server/plugins/mtg/index.test.js, pokemon/index.test.js, riftbound/index.test.js (create the file if a plugin lacks one), with the right expected value per game:

javascript
describe('getCardIdentityField', () => {
    it('names the field this game keys a card identity on', () => {
        // Verified against the model, not remembered: a wrong field name here
        // matches nothing and silently syncs zero products (§5.4).
        expect(plugin.getCardIdentityField()).toBe('sourceCardUUID'); // mtg
        // pokemon and riftbound: 'sourceCardId'
    });

    it('names a field the product model actually declares', () => {
        const Model = plugin.getProductModel();
        expect(Model.schema.path(plugin.getCardIdentityField())).toBeDefined();
    });
});

Add to server/services/syncService.test.js:

javascript
describe('cardIdentities filter', () => {
    it('reads only the listed cards, and keys on the identity field the plugin names', async () => {
        const queries = [];
        const svc = makeSyncService({
            plugin: {
                ...basePlugin,
                getCardIdentityField: () => 'sourceCardUUID',
                getProductModel: () => ({ find: (q) => { queries.push(q); return { limit: () => [] }; } })
            }
        });

        await expect(svc.syncSetDirect('10E', {
            game: 'mtg',
            cardIdentities: ['uuid-a', 'uuid-b']
        })).rejects.toThrow(/No products found/);

        expect(queries[0].sourceCardUUID).toEqual({ $in: ['uuid-a', 'uuid-b'] });
        // The set filter still applies -- the identity list narrows within the
        // set, it does not replace it.
        expect(queries[0].sourceSet).toBe('10E');
        // Collector number is NOT how this filters: a TCGplayer group spans a
        // set and its promos, where the same number is two different cards.
        expect(queries[0]['metafields.collector_number']).toBeUndefined();
    });

    it('returns the Shopify product id for each identity it synced', async () => {
        const svc = makeSyncServiceThatCreates([
            { sourceCardUUID: 'uuid-a', handle: 'adarkar-wastes-10e' }
        ], { createdId: 'gid://shopify/Product/111' });

        const results = await svc.syncSetDirect('10E', { game: 'mtg', cardIdentities: ['uuid-a'] });

        // Must come out of the run: _persistSyncMarkers writes shopifyProductId
        // to the SHARED global catalog collection, so reading it back would
        // return whichever store synced last.
        expect(results.syncedProductIds).toEqual([
            { identity: 'uuid-a', shopifyProductId: 'gid://shopify/Product/111', action: 'create' }
        ]);
    });

    it('charges the tier estimate for the listed cards only, not the whole set', async () => {
        const counted = [];
        const plugin = {
            ...basePlugin,
            getCardIdentityField: () => 'sourceCardUUID',
            getProductModel: () => ({ countDocuments: async (q) => { counted.push(q); return 2; } })
        };
        const n = await countSyncableProducts(plugin, 'mtg', ['10E'], { cardIdentities: ['uuid-a', 'uuid-b'] });
        expect(n).toBe(2);
        expect(counted[0].sourceCardUUID).toEqual({ $in: ['uuid-a', 'uuid-b'] });
    });
});

The last test matters because countSyncableProducts and syncSetDirect share buildSyncProductQuery precisely so the estimate and the sync can never disagree — the drift that CLAUDE.md §5.8 records.

  • [ ] Step 2: Run them to verify they fail
bash
npx vitest run server/services/syncService.test.js server/plugins

Expected: FAIL — plugin.getCardIdentityField is not a function.

  • [ ] Step 3: Add the plugin method to all three plugins

In server/plugins/BaseGamePlugin.js, in the SYNC INTERFACE section beside getProductModel:

javascript
    /**
     * The product-model field that carries a card's stable identity — the key
     * a caller uses to name specific cards rather than a whole set.
     *
     * Per-game because the models genuinely differ: MTG carries MTGJSON's
     * `sourceCardUUID`, Pokemon and Riftbound carry `sourceCardId`. A wrong
     * name here matches nothing and syncs zero products without erroring
     * (§5.4), so every implementation is asserted against its own schema.
     * @returns {string}
     */
    getCardIdentityField() {
        throw new Error('getCardIdentityField must be implemented by subclass');
    }

In server/plugins/mtg/index.js, directly after getProductModel():

javascript
    // ShopifyMTGProductVariant.sourceCardUUID (line 128) — "The base card UUID
    // (same for all finishes)".
    getCardIdentityField() {
        return 'sourceCardUUID';
    }

In server/plugins/pokemon/index.js:

javascript
    // ShopifyPokemonProductVariant.sourceCardId (line 120) — `${setId}-${number}`,
    // the same composite that keys pokemon_prices.slug (§5.3).
    getCardIdentityField() {
        return 'sourceCardId';
    }

In server/plugins/riftbound/index.js:

javascript
    // ShopifyRiftboundProductVariant.sourceCardId (line 114) —
    // String(tcgplayerProductId), which equals RiftboundPrice.slug.
    getCardIdentityField() {
        return 'sourceCardId';
    }
  • [ ] Step 4: Index the identity field on all three variant models

buildSyncProductQuery always includes sourceSet, so the compound index leads with it. Add to server/models/ShopifyMTGProductVariant.js beside the existing index declarations:

javascript
// The TCGplayer import names specific cards within a set rather than syncing
// the set whole (syncService buildSyncProductQuery's cardIdentities filter).
// Without this the $in scans every document in the set.
shopifyMTGProductVariantSchema.index({ sourceSet: 1, sourceCardUUID: 1 });

Mirror on the other two with their own schema variable and sourceCardId:

javascript
shopifyPokemonProductVariantSchema.index({ sourceSet: 1, sourceCardId: 1 });
javascript
shopifyRiftboundProductVariantSchema.index({ sourceSet: 1, sourceCardId: 1 });

Read each file first — the schema variable names differ. Pokemon and Riftbound have no import path today, but the index is mirrored rather than deferred: it is the same query shape their plugins now advertise support for, and a one-game index is the §5.1 shape.

  • [ ] Step 5: Thread the filter through syncService

Replace buildSyncProductQuery (line 62):

javascript
function buildSyncProductQuery(sourceSetFilter, { cardNumber, rarities, cardIdentities, identityField } = {}) {
    const query = { sourceSet: sourceSetFilter };
    if (cardNumber) query['metafields.collector_number'] = String(cardNumber).trim();
    // Names specific cards within the set. Keyed on the plugin's identity
    // field rather than collector number because a TCGplayer group spans a set
    // and its promos, where one number can be two different cards -- filtering
    // by number would re-admit the ambiguity the matcher resolved.
    if (Array.isArray(cardIdentities) && cardIdentities.length) {
        if (!identityField) throw new Error('cardIdentities requires identityField');
        query[identityField] = { $in: cardIdentities.map(String) }; // eslint-disable-line security/detect-object-injection -- identityField comes from the plugin registry, never from a request
    }
    const rarityFilter = buildRarityFilter(rarities);
    if (rarityFilter) query['metafields.rarity'] = rarityFilter;
    return query;
}

In countSyncableProducts, pass the field through:

javascript
    const query = buildSyncProductQuery({ $in: sourceSetKeys }, { ...filters, identityField: plugin.getCardIdentityField() });

In syncSetDirect, add cardIdentities to the destructure on line 437:

javascript
        const { testMode = false, testLimit = 2, cardNumber = null, rarities = null, cardIdentities = null, syncJobId, onProgress, resumeFrom = null, force = true } = options;

and to the query build on line 501:

javascript
        const query = buildSyncProductQuery(sourceSetKey, {
            cardNumber, rarities, cardIdentities, identityField: plugin.getCardIdentityField()
        });

        if (Array.isArray(cardIdentities) && cardIdentities.length) {
            logger.info(`Filtering to ${cardIdentities.length} named cards`, { shop: this.shop, setCode });
        }
  • [ ] Step 6: Return the identity → product id map

In syncSetDirect, add to the results object (line 555):

javascript
            // Which Shopify product each requested identity landed on. Returned
            // rather than read back from the catalog because _persistSyncMarkers
            // writes shopifyProductId to the SHARED global collection (see its
            // doc comment), so the stored value belongs to whichever store synced
            // last, not to this one.
            syncedProductIds: [],

At both _persistSyncMarkers call sites, record the mapping. After line 696 ('create'):

javascript
                        await _persistSyncMarkers(ProductModel, product, created.id, 'create');
                        results.syncedProductIds.push({
                            identity: product[plugin.getCardIdentityField()], // eslint-disable-line security/detect-object-injection -- plugin-supplied field name
                            shopifyProductId: created.id,
                            action: 'create'
                        });

After line 808 ('update'):

javascript
                            await _persistSyncMarkers(ProductModel, product, shopifyId, 'update');
                            results.syncedProductIds.push({
                                identity: product[plugin.getCardIdentityField()], // eslint-disable-line security/detect-object-injection -- plugin-supplied field name
                                shopifyProductId: shopifyId,
                                action: 'update'
                            });
  • [ ] Step 7: Run the tests to verify they pass
bash
npx vitest run server/services/syncService.test.js server/plugins server/models
npm test

Expected: PASS, and the full suite still green — cardIdentities defaults to null, so every existing sync builds an identical query.

  • [ ] Step 8: Commit
bash
git add server/plugins server/models/Shopify*ProductVariant.js server/services/syncService.js server/services/syncService.test.js
git commit -m "Let a sync create just the cards a merchant listed instead of the whole set"

Task 7: The confirm endpoint

Files:

  • Modify: server/routes/tcgImport.js
  • Modify: server/queues/syncQueue.js (TCG_IMPORT_RUN_JOB, addTcgImportRunJob)
  • Test: server/routes/tcgImport.test.js

Interfaces:

  • Consumes: TcgImport.status, heartbeatAt, queuedAt, priceMode (Task 1); tcgImportConfirmSchema (already in server/schemas/tcgImport.js:30).
  • Produces:
    • POST /api/tcg-import/:id/confirm { priceMode } → 202 { importId, status: 'queued', jobId }, or 403 { code: 'upgrade_required' }, 409, 400, 404.
    • syncQueue.TCG_IMPORT_RUN_JOB = 'tcg-import-run', addTcgImportRunJob({ shop, importId, accessToken }).
    • Used by Tasks 8, 9.

priceMode: 'locked' is rejected here. The spec puts the price lock in PR 3. Accepting the choice now, with nothing honouring it, means the next daily price update silently overwrites the prices the merchant chose to keep — a §5.9 dead field with a merchant-visible cost. The shared schema keeps both values (PR 3 needs no schema change); this route rejects 'locked' with a plain message, and PR 3 deletes the guard.

  • [ ] Step 1: Write the failing tests
javascript
describe('POST /tcg-import/:id/confirm', () => {
    it('claims the import, queues the run and returns 202', async () => {
        const queued = [];
        route._setDeps({
            ...baseRouteDeps,
            TcgImport: {
                ...fakeImportModel,
                findOneAndUpdate: async () => ({ _id: 'imp1', status: 'queued', game: 'mtg', tierCheck: { allowed: true } }),
                updateOne: async () => ({})
            },
            checkProductLimit: async () => ({ tier: 'rampUp', limit: 15000, current: 10, allowed: true }),
            addTcgImportRunJob: async (d) => { queued.push(d); return { id: '88' }; }
        });

        const res = await request(app).post('/api/tcg-import/507f1f77bcf86cd799439011/confirm').send({ priceMode: 'sync' });

        expect(res.status).toBe(202);
        expect(res.body.status).toBe('queued');
        expect(queued).toHaveLength(1);
    });

    it('rejects priceMode locked until the price lock exists, rather than accepting a promise it cannot keep', async () => {
        route._setDeps(baseRouteDeps);
        const res = await request(app).post('/api/tcg-import/507f1f77bcf86cd799439011/confirm').send({ priceMode: 'locked' });
        expect(res.status).toBe(400);
        expect(res.body.error).toMatch(/keep your own prices/i);
    });

    it('rejects an unknown priceMode at the schema, not the handler', async () => {
        route._setDeps(baseRouteDeps);
        const res = await request(app).post('/api/tcg-import/507f1f77bcf86cd799439011/confirm').send({ priceMode: 'whatever' });
        expect(res.status).toBe(400);
    });

    it('re-checks the tier at confirm and blocks with upgrade_required', async () => {
        route._setDeps({
            ...baseRouteDeps,
            TcgImport: {
                ...fakeImportModel,
                findOneAndUpdate: async () => ({ _id: 'imp1', status: 'queued', game: 'mtg', counts: { distinctProducts: 2829 } }),
                updateOne: async () => ({})
            },
            checkProductLimit: async () => ({ tier: 'free', limit: 500, current: 0, allowed: false })
        });

        const res = await request(app).post('/api/tcg-import/507f1f77bcf86cd799439011/confirm').send({ priceMode: 'sync' });

        expect(res.status).toBe(403);
        expect(res.body.code).toBe('upgrade_required');
    });

    it('409s when a run is already in flight', async () => {
        route._setDeps({
            ...baseRouteDeps,
            TcgImport: {
                ...fakeImportModel,
                findOneAndUpdate: async () => null,
                findOne: async () => ({ _id: 'imp1', status: 'running', heartbeatAt: new Date() })
            }
        });
        const res = await request(app).post('/api/tcg-import/507f1f77bcf86cd799439011/confirm').send({ priceMode: 'sync' });
        expect(res.status).toBe(409);
    });

    it('releases the claim when the enqueue fails, so the merchant can retry', async () => {
        const updates = [];
        route._setDeps({
            ...baseRouteDeps,
            TcgImport: {
                ...fakeImportModel,
                findOneAndUpdate: async () => ({ _id: 'imp1', status: 'queued', game: 'mtg' }),
                updateOne: async (f, u) => { updates.push(u); return {}; }
            },
            checkProductLimit: async () => ({ tier: 'rampUp', limit: 15000, current: 0, allowed: true }),
            addTcgImportRunJob: async () => { throw new Error('NOAUTH'); }
        });

        const res = await request(app).post('/api/tcg-import/507f1f77bcf86cd799439011/confirm').send({ priceMode: 'sync' });

        expect(res.status).toBe(500);
        expect(updates.some(u => u.$set && u.$set.status === 'failed')).toBe(true);
    });
});
  • [ ] Step 2: Run to verify failure
bash
npx vitest run server/routes/tcgImport.test.js -t confirm

Expected: FAIL — 404, the route does not exist.

  • [ ] Step 3: Add the run job to the queue module

In server/queues/syncQueue.js, beside TCG_IMPORT_MATCH_JOB:

javascript
const TCG_IMPORT_RUN_JOB = 'tcg-import-run';

/**
 * Queue the execution of a confirmed TCGplayer import.
 * @param {object} jobData - { shop, importId, accessToken (encrypted) }
 * @returns {Promise<Job>} The created job
 */
const addTcgImportRunJob = async (jobData, options = {}) => {
    const q = getQueue();
    const job = await q.add(TCG_IMPORT_RUN_JOB, jobData, {
        // attempts: 1, same reasoning as buylist intake: a run that creates
        // products and moves real stock should surface to the merchant rather
        // than silently re-enter 60s later. Per-line result records and the
        // progress.setsDone checkpoint make a deliberate re-run cheap and safe.
        attempts: 1,
        ...options
    });

    logger.info('TCGplayer import run job added to queue', {
        jobId: job.id, shop: jobData.shop, importId: jobData.importId
    });

    return job;
};

Export TCG_IMPORT_RUN_JOB and addTcgImportRunJob.

  • [ ] Step 4: Add the confirm handler

In server/routes/tcgImport.js, add near the top:

javascript
const { checkProductLimit } = require('../services/planService');

// A run whose heartbeat has not moved in this long is treated as dead and may
// be reclaimed. Per-line result records mean a reclaim only picks up lines that
// never wrote — the same guarantee BuylistOrder.intake relies on.
const IMPORT_STALE_MS = 10 * 60 * 1000;

Extend getDeps() with checkProductLimit, addTcgImportRunJob, and cheapestTierThatFits if the response needs it (reuse TcgImport.tierCheck.requiredTier instead — it is already computed).

javascript
async function handleConfirm(req, res) {
    const deps = getDeps();
    const { priceMode } = req.body;

    // PR 3 owns the price lock (the price_locked metafield and the
    // priceUpdateService skip that honours it). Accepting the choice before
    // anything enforces it would let the next daily price update silently
    // overwrite the prices the merchant asked us to keep.
    if (priceMode === 'locked') {
        return res.status(400).json({
            error: 'Keeping your own prices is coming in a later release. For now the import prices your cards using your store pricing rules.',
            code: 'price_lock_unavailable'
        });
    }

    try {
        // Atomic claim, not read-then-write: two clicks (or two tabs) landing
        // together would both pass a separate status check and both enqueue,
        // and two concurrent runs would double-write the same lines.
        const staleCutoff = new Date(Date.now() - IMPORT_STALE_MS);
        const claimed = await deps.TcgImport.findOneAndUpdate(
            {
                _id: req.params.id,
                shop: req.shop,
                $or: [
                    // The normal path: a finished preview, or a previous run
                    // that failed and is being retried.
                    { status: { $in: ['preview', 'failed'] } },
                    // In flight on paper, but nothing has reported progress in
                    // a long time: the worker died mid-run.
                    { status: { $in: ['queued', 'running'] }, heartbeatAt: { $lt: staleCutoff } },
                    { status: { $in: ['queued', 'running'] }, heartbeatAt: { $exists: false }, queuedAt: { $lt: staleCutoff } }
                ]
            },
            {
                $set: { status: 'queued', priceMode, queuedAt: new Date() },
                $unset: { error: '', heartbeatAt: '' }
            },
            { new: true }
        );

        if (!claimed) {
            const doc = await deps.TcgImport.findOne({ _id: req.params.id, shop: req.shop }).select('status').lean();
            if (!doc) return res.status(404).json({ error: 'Import not found' });
            if (doc.status === 'matching') return res.status(409).json({ error: 'We are still reading that file.' });
            if (doc.status === 'completed') return res.status(409).json({ error: 'That import has already run. Upload your export again to re-import.' });
            return res.status(409).json({ error: 'That import is already running.' });
        }

        // Re-check at confirm, not just at preview: the merchant may have
        // synced sets in between, or downgraded. The estimate is the same
        // conservative distinctProducts the preview reported unless
        // /exact-count refined it.
        const estimated = (claimed.counts && claimed.counts.distinctProducts) || 0;
        const limitResult = await deps.checkProductLimit(req.shop, req.accessToken, estimated);
        if (!limitResult.allowed) {
            await deps.TcgImport.updateOne(
                { _id: req.params.id, shop: req.shop },
                { $set: { status: 'preview', 'tierCheck.allowed': false, 'tierCheck.current': limitResult.current, 'tierCheck.tier': limitResult.tier } }
            );
            return res.status(403).json({
                error: `Your ${limitResult.tier} plan allows ${limitResult.limit} products and this import needs ${estimated} more.`,
                code: 'upgrade_required',
                tierCheck: { tier: limitResult.tier, limit: limitResult.limit, current: limitResult.current, estimated }
            });
        }

        const { encryptToken } = require('../utils/crypto');
        const job = await deps.addTcgImportRunJob({
            shop: req.shop,
            importId: String(req.params.id),
            accessToken: encryptToken(req.accessToken)
        });
        await deps.TcgImport.updateOne({ _id: req.params.id, shop: req.shop }, { $set: { jobId: String(job.id) } });

        logger.info('Queued TCGplayer import run', { shop: req.shop, importId: req.params.id, jobId: job.id });
        return res.status(202).json({ importId: req.params.id, status: 'queued', jobId: String(job.id) });
    } catch (error) {
        // Release the claim, or the import is wedged at 'queued' with nothing
        // coming to move it and the merchant cannot retry for 10 minutes.
        await deps.TcgImport.updateOne(
            { _id: req.params.id, shop: req.shop, status: 'queued' },
            { $set: { status: 'failed', error: 'We could not start the import — try again.' } }
        ).catch(() => {});
        logger.error('Failed to confirm TCGplayer import', { shop: req.shop, importId: req.params.id, error: error.message });
        return res.status(500).json({ error: 'We could not start the import. Try again.' });
    }
}

Wire it, and export the handler beside the others:

javascript
router.post(
    '/tcg-import/:id/confirm',
    validate(tcgImportIdParamSchema, { source: 'params' }),
    validate(tcgImportConfirmSchema),
    handleConfirm
);

Add tcgImportConfirmSchema to the destructured import from ../schemas, and delete the "Not wired to a route yet" comment above it in server/schemas/tcgImport.js.

  • [ ] Step 5: Run to verify pass
bash
npx vitest run server/routes/tcgImport.test.js

Expected: PASS.

  • [ ] Step 6: Commit
bash
git add server/routes/tcgImport.js server/routes/tcgImport.test.js server/schemas/tcgImport.js server/queues/syncQueue.js
git commit -m "Let a merchant confirm a reviewed TCGplayer import and queue it to run"

Task 8: The import run

Files:

  • Create: server/services/tcgImportRunService.js
  • Create: server/services/tcgImportRunService.test.js
  • Modify: server/queues/processors/syncProcessor.js (third branch)

Interfaces:

  • Consumes: syncSetDirect({ cardIdentities }) + results.syncedProductIds (Task 6); ensureVariantForConditionAndInventory({ quantityMode: 'set' }) (Task 5); TcgImport.progress.setsDone / heartbeatAt (Task 1); TCG_IMPORT_RUN_JOB (Task 7).
  • Produces: runImport({ shop, store, accessToken, importId, jobId }): Promise<{ error?, status?, summary? }>.

The five phases, and what each costs. Phase 1 builds the managed-collection product cache once — this is the whole reason the import is not 333 separate sync jobs. Phase 2 ensures the parent and per-set smart collections. Phase 3 runs per set code and is the checkpoint boundary. Phase 4 (sealed) is PR 4 and is not written here. Phase 5 writes the summary.

Known cost, accepted for v1: ensureVariantForConditionAndInventory opens with getProductWithVariantsGraphQL(productId) — one live fetch per line, so roughly 2,395 extra GraphQL calls on the reference export, on the order of 15–25 minutes of job wall time at Shopify's cost-aware limits. The spec accepts this because it reuses the code that already knows how to build a condition variant correctly. The optimisation (folding the condition directives into the batch buildVariantWritePayloads fan-out) touches the shared variant-write path and must be made against a measured baseline, not a guess.

  • [ ] Step 1: Write the failing tests

Create server/services/tcgImportRunService.test.js:

javascript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import runService from './tcgImportRunService.js';

const IMPORT_ID = 'imp1';
const SHOP = 's.myshopify.com';

function fakeLines(docs) {
    return {
        distinct: async () => [...new Set(docs.map(d => d.match.setCode))],
        find: () => ({ lean: () => ({ cursor: () => ({ async* [Symbol.asyncIterator]() { for (const d of docs) yield d; } }) }) }),
        updateOne: async () => ({ modifiedCount: 1 })
    };
}

const LINE_A = {
    _id: 'l1',
    raw: { productName: 'Adarkar Wastes', setName: '10th Edition' },
    parsed: { finish: 'nonfoil', condition: 'lp', quantity: 3 },
    match: { status: 'matched', setCode: '10E', cardUuid: 'uuid-a', rarity: 'rare', finish: 'nonfoil' }
};

describe('runImport', () => {
    afterEach(() => runService._resetDeps());

    it('builds the product cache exactly once for the whole file, not once per set', async () => {
        let cacheBuilds = 0;
        const syncCalls = [];
        runService._setDeps(depsWith({
            lines: fakeLines([LINE_A, { ...LINE_A, _id: 'l2', match: { ...LINE_A.match, setCode: 'M19', cardUuid: 'uuid-b' } }]),
            buildProductCache: async () => { cacheBuilds++; },
            syncSetDirect: async (setCode, opts) => {
                syncCalls.push({ setCode, cardIdentities: opts.cardIdentities });
                return { created: 1, updated: 0, failed: 0, errors: [], syncedProductIds: [{ identity: opts.cardIdentities[0], shopifyProductId: 'gid://shopify/Product/1', action: 'create' }] };
            }
        }));

        await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });

        // The reason this is an import-owned pipeline rather than 333 sync
        // jobs: 333 jobs would rebuild this cache 333 times.
        expect(cacheBuilds).toBe(1);
        expect(syncCalls).toHaveLength(2);
    });

    it('passes only the matched cards for each set, keyed on cardUuid', async () => {
        const syncCalls = [];
        runService._setDeps(depsWith({
            lines: fakeLines([LINE_A]),
            syncSetDirect: async (setCode, opts) => { syncCalls.push({ setCode, ...opts }); return okSync('uuid-a'); }
        }));

        await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });

        expect(syncCalls[0].setCode).toBe('10E');
        expect(syncCalls[0].cardIdentities).toEqual(['uuid-a']);
        expect(syncCalls[0].game).toBe('mtg');
    });

    it('sets stock absolutely so a re-import is idempotent', async () => {
        const ensured = [];
        runService._setDeps(depsWith({
            lines: fakeLines([LINE_A]),
            syncSetDirect: async () => okSync('uuid-a'),
            ensureVariantForConditionAndInventory: async (args) => { ensured.push(args); return { variantId: 'gid://shopify/ProductVariant/9', created: true }; }
        }));

        await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });

        expect(ensured[0].quantityMode).toBe('set');
        expect(ensured[0].quantity).toBe(3);
        expect(ensured[0].condition).toBe('lp');
    });

    it('records each line result as it goes, not once at the end', async () => {
        const lineWrites = [];
        const lines = fakeLines([LINE_A]);
        lines.updateOne = async (filter, update) => { lineWrites.push({ filter, update }); return {}; };
        runService._setDeps(depsWith({ lines, syncSetDirect: async () => okSync('uuid-a') }));

        await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });

        // A process death mid-run must not lose what already wrote to Shopify:
        // a re-run reads these and skips them.
        expect(lineWrites).toHaveLength(1);
        expect(lineWrites[0].update.$set['result.productId']).toBe('gid://shopify/Product/1');
        expect(lineWrites[0].update.$set['result.action']).toBe('stocked');
    });

    it('skips set codes already in progress.setsDone so a restarted worker resumes', async () => {
        const syncCalls = [];
        runService._setDeps(depsWith({
            importDoc: { _id: IMPORT_ID, shop: SHOP, game: 'mtg', status: 'queued', progress: { setsDone: ['10E'] } },
            lines: fakeLines([LINE_A, { ...LINE_A, _id: 'l2', match: { ...LINE_A.match, setCode: 'M19' } }]),
            syncSetDirect: async (setCode) => { syncCalls.push(setCode); return okSync('uuid-a'); }
        }));

        await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });

        expect(syncCalls).toEqual(['M19']);
    });

    it('records a failed line and keeps going instead of rolling the run back', async () => {
        const lineWrites = [];
        const lines = fakeLines([LINE_A, { ...LINE_A, _id: 'l2', match: { ...LINE_A.match, cardUuid: 'uuid-b' } }]);
        lines.updateOne = async (f, u) => { lineWrites.push(u); return {}; };
        let calls = 0;
        runService._setDeps(depsWith({
            lines,
            syncSetDirect: async () => ({ created: 2, updated: 0, failed: 0, errors: [], syncedProductIds: [
                { identity: 'uuid-a', shopifyProductId: 'gid://shopify/Product/1', action: 'create' },
                { identity: 'uuid-b', shopifyProductId: 'gid://shopify/Product/2', action: 'create' }
            ] }),
            ensureVariantForConditionAndInventory: async () => {
                calls++;
                if (calls === 1) throw new Error('Shopify 500');
                return { variantId: 'gid://shopify/ProductVariant/9', created: true };
            }
        }));

        const result = await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });

        expect(lineWrites[0].$set['result.error']).toContain('Shopify 500');
        expect(lineWrites[1].$set['result.action']).toBe('stocked');
        expect(result.summary.failed).toBe(1);
        expect(result.summary.stocked).toBe(1);
    });

    it('records a line the sync never produced a product for, rather than reporting it stocked', async () => {
        const lineWrites = [];
        const lines = fakeLines([LINE_A]);
        lines.updateOne = async (f, u) => { lineWrites.push(u); return {}; };
        runService._setDeps(depsWith({
            lines,
            // The card matched our catalog but the sync failed to create it —
            // a DRAFT-blocking price failure, a Shopify error. Silently
            // reporting it stocked would leave the merchant short with no trace.
            syncSetDirect: async () => ({ created: 0, updated: 0, failed: 1, errors: [{ error: 'boom' }], syncedProductIds: [] })
        }));

        const result = await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });

        expect(lineWrites[0].$set['result.error']).toMatch(/could not be created/i);
        expect(result.summary.failed).toBe(1);
    });

    it('refuses to run an import that is not queued', async () => {
        runService._setDeps(depsWith({ importDoc: { _id: IMPORT_ID, shop: SHOP, game: 'mtg', status: 'completed' } }));
        const result = await runService.runImport({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID, jobId: '88' });
        expect(result.error).toMatch(/not queued/i);
    });
});

Write these three helpers in that file:

javascript
const fakeImportModel = { findOne: async () => null, updateOne: async () => ({}), findOneAndUpdate: async () => null };

const basePlugin = {
    parentCollectionSpec: { title: 'Magic: The Gathering' },
    getSetCollectionSpec: (set) => ({ title: set.name }),
    findSet: async (code) => ({ id: code, name: code, code }),
    getCardIdentityField: () => 'sourceCardUUID',
    getProductModel: () => productModelOver([{ sourceCardUUID: 'uuid-a' }]),
    buildVariantProducts: () => [{ metafields: { finish: 'Normal', rarity: 'Rare' }, variantprice: 4.19, variantsku: 'SKU-1' }],
    findExistingProduct: () => null
};

// Cursor-shaped stand-in for a catalog product model (§5.6: the run reads
// through .cursor(), so the stub must offer one).
function productModelOver(docs) {
    return {
        findOne: async () => docs[0] || null,
        find: () => ({ lean: () => ({ cursor: () => ({ async* [Symbol.asyncIterator]() { for (const d of docs) yield d; } }) }) })
    };
}

function okSync(identity) {
    return {
        created: 1, updated: 0, failed: 0, errors: [],
        syncedProductIds: [{ identity, shopifyProductId: 'gid://shopify/Product/1', action: 'create' }]
    };
}

/**
 * A full dep object with working defaults, so each test overrides only the one
 * seam it is about. syncSetDirect and the Shopify calls hang off the
 * SyncService instance runImport constructs, so they are injected through a
 * fake constructor rather than as top-level deps.
 */
function depsWith({ importDoc, lines, syncSetDirect, buildProductCache, ensureVariantForConditionAndInventory, plugin, checkProductLimit, TcgImport } = {}) {
    const doc = importDoc || { _id: IMPORT_ID, shop: SHOP, game: 'mtg', status: 'queued', progress: { setsDone: [] } };
    return {
        TcgImport: TcgImport || { ...fakeImportModel, findOne: async () => doc, updateOne: async () => ({}) },
        TcgImportLine: lines || fakeLines([]),
        SyncService: function FakeSyncService() {
            this.batchService = { buildProductCache: buildProductCache || (async () => {}) };
            this.shopifyAPI = {
                ensureSmartCollection: async () => ({}),
                ensureVariantForConditionAndInventory: ensureVariantForConditionAndInventory
                    || (async () => ({ variantId: 'gid://shopify/ProductVariant/9', created: true }))
            };
            this.syncSetDirect = syncSetDirect || (async () => okSync('uuid-a'));
        },
        getPlugin: () => plugin || basePlugin,
        ensureManagedCollection: async () => 'gid://shopify/Collection/1',
        checkProductLimit: checkProductLimit || (async () => ({ tier: 'rampUp', limit: 15000, current: 0, allowed: true })),
        Store: { updateOne: async () => ({}) },
        invalidateShopSyncCaches: () => {}
    };
}
  • [ ] Step 2: Run to verify failure
bash
npx vitest run server/services/tcgImportRunService.test.js

Expected: FAIL — module not found.

  • [ ] Step 3: Write the run service

Create server/services/tcgImportRunService.js:

javascript
/**
 * Execute a confirmed TCGplayer import.
 *
 * Structure mirrors buylistIntakeService: every decision lives here so it is
 * testable without a queue, and the processor is a thin shell.
 *
 * The one thing this owns that a normal sync does not: it builds the managed
 * product cache ONCE for the whole file and then drives syncSetDirect per set
 * code through cardIdentities. Running the same work as 333 individual sync
 * jobs would rebuild that cache 333 times; running it as 2,395 single-card
 * syncs would fire 2,395 live Shopify lookups. Neither existing entry point
 * gives one cache build shared across many sets, which is why this file exists.
 */
'use strict';

const logger = require('../utils/logger');

let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
    if (_deps) return _deps;
    return {
        TcgImport: require('../models/TcgImport'),
        TcgImportLine: require('../models/TcgImportLine'),
        SyncService: require('./syncService'),
        getPlugin: require('../plugins').getPlugin,
        ensureManagedCollection: require('./managedCollectionService').ensureManagedCollection,
        Store: require('../models/Store'),
        invalidateShopSyncCaches: require('../utils/syncCacheInvalidation').invalidateShopSyncCaches
    };
}

/**
 * One line's condition variant and absolute stock, on a product the sync just
 * created or updated.
 *
 * Never throws: a failure is recorded on the line and the run continues. Three
 * bad rows out of 2,395 should yield 2,392 stocked cards, not a rollback.
 */
async function stockLine({ line, shopifyProductId, syncService, store, deps }) {
    const set = { 'result.productId': shopifyProductId || null };

    if (!shopifyProductId) {
        // The card matched our catalog but the sync produced no Shopify product
        // for it. Reporting it stocked would leave the merchant short with no
        // trace of which cards are missing.
        set['result.action'] = 'failed';
        set['result.error'] = 'The product could not be created in Shopify — see the sync errors for this set';
        await deps.TcgImportLine.updateOne({ _id: line._id }, { $set: set });
        return { status: 'failed' };
    }

    try {
        const result = await syncService.shopifyAPI.ensureVariantForConditionAndInventory({
            productId: shopifyProductId,
            // The catalog document's finish label, recorded by the matcher via
            // plugin.selectVariantByFinish — the same vocabulary
            // resolveFinishLabel compares against. Never a mapping table: MTG
            // carries 22 distinct finishes and collapsing them onto three
            // plugin ids matches nothing rather than erroring (§5.4).
            finish: line.match.finish,
            condition: line.parsed.condition,
            quantity: line.parsed.quantity,
            // Absolute, not a delta: this is what makes a re-import idempotent.
            quantityMode: 'set',
            nmPrice: line.nmPrice,
            sku: line.sku,
            conditionMultipliers: line.conditionMultipliers,
            rarity: line.match.rarity,
            pricingConfig: line.pricingConfig,
            barcode: line.barcode,
            uniqueKey: line.uniqueKey,
            gameCode: line.gameCode
        });

        set['result.variantId'] = result.variantId;
        set['result.action'] = 'stocked';
        set['result.error'] = null;
        await deps.TcgImportLine.updateOne({ _id: line._id }, { $set: set });
        return { status: 'stocked', created: result.created };
    } catch (error) {
        set['result.action'] = 'failed';
        set['result.error'] = error.message;
        await deps.TcgImportLine.updateOne({ _id: line._id }, { $set: set });
        return { status: 'failed' };
    }
}

async function runImport({ shop, store, accessToken, importId, jobId }) {
    const deps = getDeps();
    const { TcgImport, TcgImportLine } = deps;

    const importDoc = await TcgImport.findOne({ _id: importId, shop });
    if (!importDoc) return { error: 'Import not found', status: 404 };
    if (importDoc.status !== 'queued') {
        return { error: `Cannot run an import with status ${importDoc.status} — it is not queued`, status: 409 };
    }

    const game = importDoc.game;
    const plugin = deps.getPlugin(game);

    await TcgImport.updateOne(
        { _id: importId, shop },
        { $set: { status: 'running', startedAt: new Date(), jobId: jobId || null, heartbeatAt: new Date(), 'progress.phase': 'cache' }, $unset: { error: '' } }
    );

    const summary = { stocked: 0, failed: 0, productsCreated: 0, productsUpdated: 0, setsDone: 0 };

    try {
        const syncService = new deps.SyncService(shop, accessToken, {
            enabledOptionalMetafields: (store && store.metafieldConfig && store.metafieldConfig.enabledOptional) || {}
        });

        // === PHASE 1: one product cache for the whole file ===
        const managedCollectionId = await deps.ensureManagedCollection(shop, accessToken);
        try {
            await syncService.batchService.buildProductCache(managedCollectionId);
        } catch (err) {
            // Self-heal if the merchant deleted the cached collection — same
            // recovery syncProcessor phase 1 does.
            if (err.code !== 'MANAGED_COLLECTION_MISSING') throw err;
            logger.warn('Managed collection missing on Shopify; recreating for import', { shop, staleCollectionId: managedCollectionId });
            await deps.Store.updateOne({ shop }, { $set: { managedCollectionId: null } });
            await syncService.batchService.buildProductCache(await deps.ensureManagedCollection(shop, accessToken));
        }

        // === PHASE 2: collections ===
        await TcgImport.updateOne({ _id: importId, shop }, { $set: { 'progress.phase': 'collections', heartbeatAt: new Date() } });
        await syncService.shopifyAPI.ensureSmartCollection(plugin.parentCollectionSpec);

        // Bounded: 333 set codes on the reference export, and MAX_IMPORT_ROWS
        // caps it well below anything worth streaming.
        const allSetCodes = await TcgImportLine.distinct('match.setCode', {
            importId: importDoc._id, shop, 'match.status': 'matched'
        });
        const done = new Set((importDoc.progress && importDoc.progress.setsDone) || []);
        const setCodes = allSetCodes.filter((code) => code && !done.has(code));

        for (const setCode of setCodes) {
            const set = await plugin.findSet(setCode);
            if (set) await syncService.shopifyAPI.ensureSmartCollection(plugin.getSetCollectionSpec(set));
        }
        deps.invalidateShopSyncCaches(shop);

        // === PHASE 3: per set code, checkpointed ===
        await TcgImport.updateOne({ _id: importId, shop }, { $set: { 'progress.phase': 'products', heartbeatAt: new Date() } });

        for (const setCode of setCodes) {
            // One set's lines at a time. Cursor rather than find(): a large set
            // in a 20,000-row import is exactly the shape §5.6 warns about.
            const lines = [];
            const cursor = TcgImportLine
                .find({ importId: importDoc._id, shop, 'match.setCode': setCode, 'match.status': 'matched' })
                .lean()
                .cursor();
            for await (const line of cursor) lines.push(line);

            const cardIdentities = [...new Set(lines.map((l) => l.match.cardUuid).filter(Boolean))];
            let syncResult;
            try {
                syncResult = await syncService.syncSetDirect(setCode, { game, cardIdentities, force: true });
            } catch (error) {
                logger.error('TCGplayer import: set sync failed', { shop, importId: String(importId), setCode, error: error.message });
                for (const line of lines) {
                    await TcgImportLine.updateOne({ _id: line._id }, { $set: { 'result.action': 'failed', 'result.error': `Set sync failed: ${error.message}` } });
                    summary.failed++;
                }
                await TcgImport.updateOne(
                    { _id: importId, shop },
                    { $addToSet: { 'progress.setsDone': setCode }, $inc: { 'progress.failed': lines.length }, $set: { heartbeatAt: new Date() } }
                );
                continue;
            }

            summary.productsCreated += syncResult.created || 0;
            summary.productsUpdated += syncResult.updated || 0;

            // Identity -> Shopify product id, from THIS run. Not read back from
            // the catalog: _persistSyncMarkers writes shopifyProductId to the
            // shared global collection, so the stored value belongs to whichever
            // store synced last (syncService.js:202).
            const productIdByIdentity = new Map(
                (syncResult.syncedProductIds || []).map((entry) => [entry.identity, entry.shopifyProductId])
            );

            for (const line of lines) {
                const outcome = await stockLine({
                    line,
                    shopifyProductId: productIdByIdentity.get(line.match.cardUuid) || null,
                    syncService,
                    store,
                    deps
                });
                if (outcome.status === 'stocked') summary.stocked++; else summary.failed++;
            }

            summary.setsDone++;
            // Checkpoint after the set, with the heartbeat: a restarted worker
            // re-enters at the next unfinished set, and a stale heartbeat is
            // what lets the merchant reclaim a dead run.
            await TcgImport.updateOne(
                { _id: importId, shop },
                {
                    $addToSet: { 'progress.setsDone': setCode },
                    $set: {
                        heartbeatAt: new Date(),
                        'progress.productsCreated': summary.productsCreated,
                        'progress.productsUpdated': summary.productsUpdated,
                        'progress.inventorySet': summary.stocked,
                        'progress.failed': summary.failed
                    }
                }
            );
        }

        // Collections may be new; cleared again here because once is not enough
        // — see syncCacheInvalidation.js.
        deps.invalidateShopSyncCaches(shop);

        // === PHASE 5: summary ===
        await TcgImport.updateOne(
            { _id: importId, shop },
            {
                $set: {
                    // 'completed' means the run finished, not that every line
                    // succeeded. Only a run that could not start at all fails.
                    status: 'completed',
                    completedAt: new Date(),
                    heartbeatAt: new Date(),
                    'progress.phase': 'done',
                    summary: `Stocked ${summary.stocked} cards across ${summary.setsDone} sets (${summary.productsCreated} new products, ${summary.failed} failed)`
                }
            }
        );

        logger.info('TCGplayer import completed', { shop, importId: String(importId), ...summary });
        return { status: 'completed', summary };
    } catch (error) {
        logger.error('TCGplayer import run failed', { shop, importId: String(importId), error: error.message });
        await TcgImport.updateOne(
            { _id: importId, shop },
            { $set: { status: 'failed', completedAt: new Date(), error: `The import stopped: ${error.message}` } }
        );
        return { error: error.message, status: 500, summary };
    }
}

module.exports = { runImport, stockLine, _setDeps, _resetDeps };

The variant arguments are derived, not stored. nmPrice, sku, barcode, uniqueKey and gameCode are not on the line document — they come from the catalog doc's built variant products, exactly as syncService._applyCardInventoryShortcut derives them (syncService.js:929-934). An omitted nmPrice prices the condition variant at zero, so this must be computed, never defaulted. Add this helper to tcgImportRunService.js above stockLine, and call it per line:

javascript
/**
 * The variant-shaped arguments ensureVariantForConditionAndInventory needs,
 * derived from the catalog document the matcher recorded.
 *
 * Same derivation as syncService._applyCardInventoryShortcut (syncService.js:929)
 * -- deliberately the same, because a second way of picking the target variant
 * would drift from the one the sync itself uses (§5.8). nmPrice in particular
 * must be real: omitted, the condition variant is created priced at zero.
 *
 * @returns {object|null} null when the catalog document has vanished since the
 *   preview, which the caller records as a line failure rather than guessing.
 */
async function variantArgsForLine({ line, plugin, identityField, deps }) {
    const ProductModel = plugin.getProductModel();
    const doc = await ProductModel.findOne({ [identityField]: line.match.cardUuid }).lean(); // eslint-disable-line security/detect-object-injection -- plugin-supplied field name
    if (!doc) return null;

    const variantProducts = plugin.buildVariantProducts(doc);
    if (!variantProducts || !variantProducts.length) return null;

    // match.finish is the catalog document's own finish value, recorded by
    // matchLine via plugin.selectVariantByFinish. Compare against
    // metafields.finish, which buildVariantProducts sets from that same field
    // -- both sides are one vocabulary, so no mapping table (§5.4).
    const wanted = String(line.match.finish || 'Normal').toLowerCase();
    const target = variantProducts.find(
        (v) => String((v.metafields && v.metafields.finish) || 'Normal').toLowerCase() === wanted
    ) || variantProducts[0];

    return {
        finish: (target.metafields && target.metafields.finish) || 'Normal',
        nmPrice: target.variantprice || variantProducts[0].variantprice || 0,
        sku: target.variantsku || variantProducts[0].variantsku,
        rarity: String((target.metafields && target.metafields.rarity) || line.match.rarity || 'common').toLowerCase(),
        // Barcode identity trio — present only when the store has barcode
        // delivery enabled (see applyBarcodePolicy).
        barcode: target.barcode,
        uniqueKey: target.uniqueKey,
        gameCode: target.gameCode
    };
}

conditionMultipliers and pricingConfig are per-store, not per-line: read them once at the top of runImport and pass them down, rather than re-reading the Store document 2,395 times.

javascript
    const { getGamePricingConfig } = require('./pricingConfigService');
    // Synchronous, and takes the Store *document* — not a shop string
    // (pricingConfigService.js:114). Verified, not assumed.
    const pricingConfig = getGamePricingConfig(store, game);
    const conditionMultipliers = pricingConfig
        && pricingConfig.conditionVariants
        && pricingConfig.conditionVariants.conditionMultipliers;

stockLine's signature therefore becomes stockLine({ line, shopifyProductId, variantArgs, pricingConfig, conditionMultipliers, syncService, deps }), and the ensureVariantForConditionAndInventory call spreads variantArgs in place of the line.* reads shown above. A line whose variantArgs is null is recorded as failed with 'That card is no longer in our catalog' — never stocked with guessed values.

  • [ ] Step 4: Branch the processor

In server/queues/processors/syncProcessor.js, extend the import to include TCG_IMPORT_RUN_JOB, add:

javascript
/**
 * TCGplayer import execution. Thin by design — every decision lives in
 * tcgImportRunService, which is testable without a queue. Failures are
 * recorded on the import rather than thrown, so BullMQ does not retry a run
 * that has already created products.
 */
const tcgImportRunProcessor = async (job) => {
    const { shop, importId, accessToken: encryptedToken } = job.data;
    logger.info('Processing TCGplayer import run', { jobId: job.id, shop, importId });

    const { runImport } = require('../../services/tcgImportRunService');
    const store = await Store.findOne({ shop });
    if (!store) throw new Error(`Store ${shop} not found`);

    const result = await runImport({
        shop,
        store,
        accessToken: resolveStoreAccessToken(store, encryptedToken),
        importId,
        jobId: String(job.id)
    });

    if (result.error) {
        logger.warn('TCGplayer import did not complete', { shop, importId, error: result.error });
        return { ok: false, error: result.error };
    }
    return { ok: true, summary: result.summary };
};

and the branch:

javascript
    if (job.name === TCG_IMPORT_RUN_JOB) {
        return tcgImportRunProcessor(job);
    }
  • [ ] Step 5: Run to verify pass
bash
npx vitest run server/services/tcgImportRunService.test.js server/queues
npm test

Expected: PASS.

  • [ ] Step 6: Commit
bash
git add server/services/tcgImportRunService.js server/services/tcgImportRunService.test.js server/queues/processors/syncProcessor.js
git commit -m "Create and stock the cards a merchant listed in their TCGplayer export"

Task 9: Run and progress in the UI

Files:

  • Modify: client/src/utils/api.js
  • Modify: client/src/pages/catalog/CatalogImportPage.jsx (replace the disabled button and the "coming next" alert at lines 238-247)
  • Test: client/src/pages/catalog/CatalogImportPage.test.jsx

Interfaces:

  • Consumes: POST /tcg-import/:id/confirm (Task 7); progress and status on the import doc (Tasks 1, 8).

  • Produces: confirmTcgImport(id, priceMode); a page that runs an import and shows progress.

  • [ ] Step 1: Write the failing tests

javascript
it('runs the import and shows progress until it completes', async () => {
    vi.spyOn(api, 'previewTcgImport').mockResolvedValue({ importId: 'imp1', status: 'matching', counts: {} });
    vi.spyOn(api, 'getTcgImport')
        .mockResolvedValueOnce(previewDoc())
        .mockResolvedValueOnce({ ...previewDoc(), status: 'running', progress: { phase: 'products', inventorySet: 40, failed: 1 } })
        .mockResolvedValue({ ...previewDoc(), status: 'completed', summary: 'Stocked 120 cards across 30 sets (95 new products, 1 failed)' });
    const confirm = vi.spyOn(api, 'confirmTcgImport').mockResolvedValue({ importId: 'imp1', status: 'queued', jobId: '88' });

    renderPage();
    await userEventUpload(screen.getByLabelText(/choose a file|upload/i), new File(['TCGplayer Id,x'], 'export.csv', { type: 'text/csv' }));
    await vi.advanceTimersByTimeAsync(4000);

    await userEvent.click(await screen.findByRole('button', { name: /run import/i }));
    expect(confirm).toHaveBeenCalledWith('imp1', 'sync');

    await vi.advanceTimersByTimeAsync(4000);
    expect(screen.getByText(/40/)).toBeInTheDocument();
    await vi.advanceTimersByTimeAsync(4000);
    expect(screen.getByText(/Stocked 120 cards/i)).toBeInTheDocument();
});

it('sends the merchant to plans when the tier check blocks the run', async () => {
    vi.spyOn(api, 'previewTcgImport').mockResolvedValue({ importId: 'imp1', status: 'matching', counts: {} });
    vi.spyOn(api, 'getTcgImport').mockResolvedValue(previewDoc());
    vi.spyOn(api, 'confirmTcgImport').mockRejectedValue({ response: { status: 403, data: { code: 'upgrade_required', error: 'Your free plan allows 500 products and this import needs 2829 more.' } } });

    renderPage();
    await userEventUpload(screen.getByLabelText(/choose a file|upload/i), new File(['TCGplayer Id,x'], 'export.csv', { type: 'text/csv' }));
    await vi.advanceTimersByTimeAsync(4000);
    await userEvent.click(await screen.findByRole('button', { name: /run import/i }));

    expect(await screen.findByText(/2829 more/)).toBeInTheDocument();
    expect(screen.getByRole('link', { name: /view plans/i })).toBeInTheDocument();
});
  • [ ] Step 2: Run to verify failure
bash
npm run test:client -- CatalogImportPage

Expected: FAIL — no Run import button is enabled.

  • [ ] Step 3: Add the api function

In client/src/utils/api.js, beside the other TCG import functions:

javascript
/**
 * Confirm a reviewed import and queue it to run.
 * @param {string} id - Import id
 * @param {'sync'} priceMode - Only 'sync' is accepted today; the server rejects
 *   'locked' until the price lock ships (server/routes/tcgImport.js handleConfirm).
 */
export async function confirmTcgImport(id, priceMode) {
  const res = await api.post(`/tcg-import/${id}/confirm`, { priceMode });
  return res.data;
}
  • [ ] Step 4: Replace the placeholder in the page

Delete the "Running the import is coming next" alert and the disabled button (lines 238-247), and put in their place:

javascript
          {importDoc.status === 'preview' && (
            <div className="space-y-2">
              {confirmError && (
                <Alert status="error">
                  <Alert.Description>
                    {confirmError.message}
                    {confirmError.upgrade && (
                      <>
                        {' '}
                        <Link className="underline font-medium" to={buildShopAwarePath('/billing', searchParams)}>
                          View plans
                        </Link>
                      </>
                    )}
                  </Alert.Description>
                </Alert>
              )}
              <Text as="p" className="text-sm">
                This creates {n(importDoc.counts?.distinctProducts)} products and sets stock to the
                quantities in your file. Cards we couldn&apos;t match are skipped. We&apos;ll price them
                using your store&apos;s pricing rules.
              </Text>
              {/* Spec preview warning #3. Two things merchants get wrong here:
                  they expect all five conditions, and they expect their
                  store-wide condition setting to change. Neither happens. */}
              <Text as="p" className="text-sm">
                We&apos;ll add only the conditions in your file, plus Near Mint — Shopify&apos;s
                Condition option requires it, so a card you listed only as Lightly Played still
                gets a Near Mint variant at our computed price. Your store-wide condition setting
                isn&apos;t changed.
              </Text>
              <Button onClick={handleRun} disabled={running || !importDoc.counts?.matched}>
                <Upload className="h-4 w-4 mr-1" />
                {running ? 'Starting…' : 'Run import'}
              </Button>
            </div>
          )}

          {(importDoc.status === 'queued' || importDoc.status === 'running') && (
            <Card className="p-4">
              <Text as="h3" className="font-bold mb-1">Importing your cards…</Text>
              <Text as="p" className="text-sm">
                {importDoc.status === 'queued'
                  ? 'Waiting for a worker to pick this up.'
                  : `${n(importDoc.progress?.inventorySet)} cards stocked so far` +
                    (importDoc.progress?.failed ? `, ${n(importDoc.progress.failed)} failed` : '') + '.'}
              </Text>
              <Text as="p" className="text-sm mt-1">
                A large import takes 15–25 minutes. You can leave this page — it keeps running.
              </Text>
            </Card>
          )}

          {importDoc.status === 'completed' && (
            <Alert status="success">
              <Alert.Description>{importDoc.summary}</Alert.Description>
            </Alert>
          )}

Add the handler and its state:

javascript
  const [running, setRunning] = useState(false);
  const [confirmError, setConfirmError] = useState(null);

  const handleRun = async () => {
    setConfirmError(null);
    setRunning(true);
    try {
      // priceMode is 'sync' only: the server rejects 'locked' until the price
      // lock ships (server/routes/tcgImport.js handleConfirm). This is a UX
      // mirror of that server rule, not the enforcement.
      await confirmTcgImport(importDoc._id, 'sync');
      setStaged({ importId: importDoc._id });  // restart polling for the run
    } catch (err) {
      const data = err?.response?.data;
      setConfirmError({
        message: data?.error || 'We could not start the import. Try again.',
        upgrade: data?.code === 'upgrade_required'
      });
    } finally {
      setRunning(false);
    }
  };

Widen the polling effect's stop condition so it keeps polling through the run — replace if (doc.status !== 'matching') with:

javascript
        const settled = doc.status === 'preview' || doc.status === 'completed' || doc.status === 'failed';
        if (settled) {
          setBusy(false);
          if (doc.status === 'failed') setError(doc.error || 'We could not finish that import.');
          clearInterval(pollRef.current);
        }
  • [ ] Step 5: Run to verify pass
bash
npm run test:client -- CatalogImportPage
npm run build

Expected: PASS, build exits 0.

  • [ ] Step 6: Commit
bash
git add client/src/utils/api.js client/src/pages/catalog/CatalogImportPage.jsx client/src/pages/catalog/CatalogImportPage.test.jsx
git commit -m "Let merchants run a reviewed TCGplayer import and watch it finish"

Task 10: The exact-count action

The preview's tier estimate counts every distinct matched product as new, because knowing otherwise needs the managed-collection cache. A merchant who already synced most of those sets is told to upgrade for products they already own. The spec's answer: a "check exactly" action that runs the cache build as a background job and re-reports with tierCheck.exact = true.

Files:

  • Modify: server/routes/tcgImport.js, server/queues/syncQueue.js, server/queues/processors/syncProcessor.js
  • Modify: server/services/tcgImportRunService.js (add runExactCount)
  • Modify: client/src/utils/api.js, client/src/pages/catalog/CatalogImportPage.jsx
  • Test: server/services/tcgImportRunService.test.js, server/routes/tcgImport.test.js

Interfaces:

  • Consumes: TcgImport.tierCheck.exact (already declared by PR 1); the cache build (Task 8).

  • Produces: POST /api/tcg-import/:id/exact-count → 202; runExactCount({ shop, store, accessToken, importId }); requestTcgImportExactCount(id) on the client.

  • [ ] Step 1: Write the failing test

javascript
it('counts only the matched cards the store does not already have', async () => {
    const updates = [];
    runService._setDeps(depsWith({
        importDoc: { _id: IMPORT_ID, shop: SHOP, game: 'mtg', status: 'preview', counts: { distinctProducts: 3 } },
        lines: fakeLines([
            LINE_A,
            { ...LINE_A, _id: 'l2', match: { ...LINE_A.match, cardUuid: 'uuid-b' } },
            { ...LINE_A, _id: 'l3', match: { ...LINE_A.match, cardUuid: 'uuid-c' } }
        ]),
        // uuid-a is already in the store's managed collection. findExistingProduct
        // lives on the PLUGIN, not the dep object — depsWith must fold this into
        // the plugin stub it builds, not add a top-level dep.
        plugin: {
            ...basePlugin,
            getCardIdentityField: () => 'sourceCardUUID',
            getProductModel: () => productModelOver([
                { sourceCardUUID: 'uuid-a' }, { sourceCardUUID: 'uuid-b' }, { sourceCardUUID: 'uuid-c' }
            ]),
            findExistingProduct: (batchService, product) => (product.sourceCardUUID === 'uuid-a' ? { id: 'gid://shopify/Product/1' } : null)
        },
        checkProductLimit: async (shop, token, count) => ({ tier: 'starter', limit: 2000, current: 1, allowed: count <= 1999 }),
        TcgImport: { ...fakeImportModel, updateOne: async (f, u) => { updates.push(u); return {}; } }
    }));

    await runService.runExactCount({ shop: SHOP, store: {}, accessToken: 'tok', importId: IMPORT_ID });

    const set = updates[updates.length - 1].$set;
    // Nobody should upgrade for products they already own.
    expect(set['tierCheck.estimated']).toBe(2);
    expect(set['tierCheck.exact']).toBe(true);
    expect(set.status).toBe('preview');
});
  • [ ] Step 2: Run to verify failure
bash
npx vitest run server/services/tcgImportRunService.test.js -t 'exact'

Expected: FAIL — runExactCount is not a function.

  • [ ] Step 3: Implement runExactCount

Add to server/services/tcgImportRunService.js:

javascript
/**
 * Refine the preview's conservative product estimate by actually checking which
 * matched cards the store already carries.
 *
 * The preview cannot do this: it would mean paginating the merchant's whole
 * managed-products collection inside an HTTP request. Here it is one cache
 * build, the same one the run itself needs, so the merchant pays for it only
 * when the conservative estimate would push them into an upgrade they may not
 * need.
 *
 * Never leaves the import in a non-preview state: this is a read, and a failure
 * must not block the merchant from confirming with the conservative number.
 */
async function runExactCount({ shop, store, accessToken, importId }) {
    const deps = getDeps();
    const { TcgImport, TcgImportLine } = deps;

    const importDoc = await TcgImport.findOne({ _id: importId, shop });
    if (!importDoc) return { error: 'Import not found', status: 404 };

    const game = importDoc.game;
    const plugin = deps.getPlugin(game);
    const ProductModel = plugin.getProductModel();
    const identityField = plugin.getCardIdentityField();

    try {
        const syncService = new deps.SyncService(shop, accessToken, {
            enabledOptionalMetafields: (store && store.metafieldConfig && store.metafieldConfig.enabledOptional) || {}
        });
        await syncService.batchService.buildProductCache(await deps.ensureManagedCollection(shop, accessToken));

        const identities = await TcgImportLine.distinct('match.cardUuid', {
            importId: importDoc._id, shop, 'match.status': 'matched'
        });

        let newProducts = 0;
        // Chunked: `identities` is bounded by distinct matched products (2,282
        // on the reference export), but the documents behind them are not
        // something to materialise in one query (§5.6).
        const CHUNK = 250;
        for (let i = 0; i < identities.length; i += CHUNK) {
            const chunk = identities.slice(i, i + CHUNK);
            const cursor = ProductModel
                .find({ [identityField]: { $in: chunk } }) // eslint-disable-line security/detect-object-injection -- plugin-supplied field name
                .lean()
                .cursor();
            for await (const doc of cursor) {
                if (!plugin.findExistingProduct(syncService.batchService, doc)) newProducts++;
            }
        }

        const limitResult = await deps.checkProductLimit(shop, accessToken, newProducts);
        await TcgImport.updateOne(
            { _id: importId, shop },
            {
                $set: {
                    status: 'preview',
                    'tierCheck.tier': limitResult.tier,
                    'tierCheck.limit': limitResult.limit,
                    'tierCheck.current': limitResult.current,
                    'tierCheck.estimated': newProducts,
                    'tierCheck.allowed': limitResult.allowed,
                    'tierCheck.exact': true
                }
            }
        );

        return { status: 'preview', estimated: newProducts, allowed: limitResult.allowed };
    } catch (error) {
        logger.error('TCGplayer import exact count failed', { shop, importId: String(importId), error: error.message });
        // Leave tierCheck.exact false and the conservative estimate standing —
        // the merchant can still confirm.
        return { error: error.message, status: 500 };
    }
}

Add runExactCount to the exports, add checkProductLimit: require('./planService').checkProductLimit to getDeps().

  • [ ] Step 4: Add the route, job and client action

In server/queues/syncQueue.js:

javascript
const TCG_IMPORT_EXACT_COUNT_JOB = 'tcg-import-exact-count';

/**
 * Queue the exact new-product count for a previewed TCGplayer import.
 * @param {object} jobData - { shop, importId, accessToken (encrypted) }
 * @returns {Promise<Job>} The created job
 */
const addTcgImportExactCountJob = async (jobData, options = {}) => {
    const q = getQueue();
    const job = await q.add(TCG_IMPORT_EXACT_COUNT_JOB, jobData, {
        // attempts: 1 for the same reason as the other two import jobs — this
        // one is a read, but a retry would rebuild the whole managed-product
        // cache again, which is the expensive part.
        attempts: 1,
        ...options
    });

    logger.info('TCGplayer import exact-count job added to queue', {
        jobId: job.id, shop: jobData.shop, importId: jobData.importId
    });

    return job;
};

Export TCG_IMPORT_EXACT_COUNT_JOB and addTcgImportExactCountJob.

In server/queues/processors/syncProcessor.js, add the import and a fourth branch:

javascript
/**
 * TCGplayer import exact new-product count. Thin by design — the decision lives
 * in tcgImportRunService.runExactCount.
 *
 * runExactCount records its own failure and leaves the conservative estimate
 * standing, so this never throws the merchant back to a blocked screen.
 */
const tcgImportExactCountProcessor = async (job) => {
    const { shop, importId, accessToken: encryptedToken } = job.data;
    logger.info('Processing TCGplayer import exact count', { jobId: job.id, shop, importId });

    const { runExactCount } = require('../../services/tcgImportRunService');
    const store = await Store.findOne({ shop });
    if (!store) throw new Error(`Store ${shop} not found`);

    const result = await runExactCount({
        shop,
        store,
        accessToken: resolveStoreAccessToken(store, encryptedToken),
        importId
    });

    if (result.error) return { ok: false, error: result.error };
    return { ok: true, estimated: result.estimated, allowed: result.allowed };
};
javascript
    if (job.name === TCG_IMPORT_EXACT_COUNT_JOB) {
        return tcgImportExactCountProcessor(job);
    }

Route:

javascript
async function handleExactCount(req, res) {
    const deps = getDeps();
    try {
        const doc = await deps.TcgImport.findOne({ _id: req.params.id, shop: req.shop }).select('status').lean();
        if (!doc) return res.status(404).json({ error: 'Import not found' });
        if (doc.status !== 'preview') return res.status(409).json({ error: 'That import is not waiting for review.' });

        const { encryptToken } = require('../utils/crypto');
        const job = await deps.addTcgImportExactCountJob({
            shop: req.shop, importId: String(req.params.id), accessToken: encryptToken(req.accessToken)
        });
        return res.status(202).json({ importId: req.params.id, jobId: String(job.id) });
    } catch (error) {
        logger.error('Failed to queue TCGplayer import exact count', { shop: req.shop, importId: req.params.id, error: error.message });
        return res.status(500).json({ error: 'We could not check that right now. Try again.' });
    }
}
javascript
router.post(
    '/tcg-import/:id/exact-count',
    validate(tcgImportIdParamSchema, { source: 'params' }),
    handleExactCount
);

Client (api.js):

javascript
export async function requestTcgImportExactCount(id) {
  const res = await api.post(`/tcg-import/${id}/exact-count`);
  return res.data;
}

Page — inside the tier block, only when the estimate blocks and is not yet exact:

javascript
          {importDoc.tierCheck && !importDoc.tierCheck.allowed && !importDoc.tierCheck.exact && (
            <Alert status="warning">
              <Alert.Description>
                <Text as="p" className="text-sm">
                  This count assumes every matched card is new to your store. If you&apos;ve already
                  synced some of these sets, the real number is lower.
                </Text>
                <Button size="sm" className="mt-2" onClick={handleExactCount} disabled={checking}>
                  {checking ? 'Checking…' : 'Check exactly'}
                </Button>
              </Alert.Description>
            </Alert>
          )}

with

javascript
  const [checking, setChecking] = useState(false);

  const handleExactCount = async () => {
    setChecking(true);
    try {
      await requestTcgImportExactCount(importDoc._id);
      // The existing poll picks up tierCheck.exact when the job lands.
      setStaged({ importId: importDoc._id });
    } catch (err) {
      setError(err?.response?.data?.error || 'We could not check that right now.');
    } finally {
      setChecking(false);
    }
  };

Note the polling effect settles on status === 'preview', which this job never leaves — so make the exact-count poll stop after tierCheck.exact === true as well, or the page stops polling immediately. Handle it by tracking a waitingForExact flag alongside the settle condition.

  • [ ] Step 5: Run to verify pass
bash
npx vitest run server/services/tcgImportRunService.test.js server/routes/tcgImport.test.js
npm run test:client -- CatalogImportPage

Expected: PASS.

  • [ ] Step 6: Commit
bash
git add server client/src/utils/api.js client/src/pages/catalog/CatalogImportPage.jsx
git commit -m "Stop telling merchants to upgrade for cards they have already synced"

Task 11: Documentation, parity statement, and end-to-end verification

Files:

  • Modify: docs/guides/tcgplayer-import.md

  • Test: nothing new; this task runs the §7 bars.

  • [ ] Step 1: Update the merchant guide

docs/guides/tcgplayer-import.md currently documents the report only. Add a "Running the import" section covering: what gets created (only the listed cards, not whole sets); that stock is set to the file's Total Quantity, so re-importing the same file is safe but a Shopify-only sale between exports is reverted; that only the conditions in the file are created, plus Near Mint, and the store-wide condition toggle is unchanged; that pricing uses the store's own pricing rules and keeping the file's prices is not available yet; that a large import takes 15–25 minutes and keeps running if the page is closed; and that unmatched rows are skipped and listed. Remove any "coming soon" wording.

bash
npm run docs:build
  • [ ] Step 2: Write the parity statement for the PR description

Copy this into the PR body, checking each claim before you post it (§5.1):

Game parity. getCardIdentityField() is implemented on all three plugins and asserted against each model's own schema: mtg → sourceCardUUID, pokemon → sourceCardId, riftbound → sourceCardId. The compound {sourceSet, <identity>} index is mirrored on all three variant models. The import surface itself stays MTG-only by decision — pokemon and riftbound are exempt because unverified: no real Pokemon or Riftbound TCGplayer export was available, so the Product Line literal they write is unknown and inventing it is the §5.4 failure mode. routes/tcgImport.js rejects a non-MTG game explicitly rather than defaulting.

  • [ ] Step 3: Run the full quality bar
bash
npm test
npm run test:client
npm run lint
npm run build
npm run test:coverage

Read the per-file coverage table by hand for every file this plan touched — the configured gate is global, not per-file, so it will not enforce the 70% bar for you. Every modified file must be ≥70% on all four metrics.

  • [ ] Step 4: Grep the §5 failure-mode checks

Each must return zero hits in the diff:

bash
git diff main --unified=0 | grep -nE "\|\| 'mtg'|\?\? 'mtg'|= 'mtg'"
bash
git diff main --name-only | xargs grep -n "myshopify.com" | grep -v shopifyAPI.js
bash
git diff main --unified=0 | grep -nE "await (TcgImportLine|Price|.*Model)\.find\(" | grep -v cursor
  • [ ] Step 5: Run the end-to-end bar

This is the §7 sync bar adapted to the import, and it is the only check that proves the metafield ⇄ collection-rule match holds for cards created through cardIdentities. Confirm MONGODB_URI reads localhost before running anything — the local .env is sometimes pointed at live Atlas (§8).

  1. docker compose up -d && npm run dev
  2. Upload a 20-row slice of the reference export (server/services/__fixtures__/tcgplayer-pricing-export-sample.csv) against the dev store ufkes-dev-2.myshopify.com.
  3. Confirm the report arrives via polling, not a hung request — watch the network tab for the 202 and the GET /tcg-import/:id sequence.
  4. Press Run import.
  5. Assert in Shopify admin: each matched card exists; its Condition option carries the condition from the file plus Near Mint; its stock equals the file's Total Quantity; and the set's smart collection actually contains the product.
  6. Re-upload the same file and run it again. Stock must be unchanged, not doubled. This is the idempotency claim the absolute-set decision rests on, and it is the one thing no unit test can prove.
  • [ ] Step 6: Commit and open the PR
bash
git add docs/guides/tcgplayer-import.md
git commit -m "Tell merchants what running a TCGplayer import does to their store"

Follow-ups this plan deliberately leaves open

  • PR 3 — price lock. price_locked product metafield, honoured by priceUpdateService, plus the merchant's opt-out choice. Deletes the priceMode: 'locked' rejection in Task 7.
  • PR 4 — sealed. Unopened rows through sealedQuickAddService (already extracted, already takes an optional price) with an absolute-quantity mode. Opens by verifying SealedProduct.identifiers.tcgplayerProductId on real documents — that side of the join was never confirmed.
  • The per-card product fetch. ensureVariantForConditionAndInventory opens with getProductWithVariantsGraphQL per line. Folding condition directives into the batch buildVariantWritePayloads fan-out would remove ~2,395 GraphQL calls per import, but it touches the shared variant-write path — do it against a measured baseline.
  • Catalog gaps, not matcher bugs. ~45 unmatched rows are Art Series sets we resolve correctly but carry zero documents for (AONE = 0 docs). That is a catalog-ingest question, not something this arc can fix.
  • syncProcessor.js:83's game = 'mtg' default. A pre-existing §5.5 violation on the shared sync path. Out of scope here; worth its own PR.
  • Convergence with buylistIntakeService. Both now stock matched card lines through syncService. The import needs one shared cache build and the intake does not (60 lines vs 2,395), so they stay separate for now — but if a third consumer appears, the spec's deferred Approach C (a public card-list sync capability) becomes the right call.