Skip to content

Sealed Barcode Repair Script (PR 2) 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: Repair sealed products already synced with a synthetic LGSFโ€ฆ barcode โ€” replacing it with the real UPC when known, clearing it otherwise โ€” without ever touching a barcode the merchant set themselves.

Architecture: A standalone migration script reads each synced sealed product's current Shopify barcode through shopifyAPI.js, classifies it with a pure function, and writes only when the value carries our own LGSF prefix. Dry-run by default. A hard guard aborts if the SealedProductBarcode reference table is empty, because clearing against an empty table would strip every synthetic code with nothing able to refill it.

Tech Stack: Node.js (CommonJS), Mongoose, Shopify Admin GraphQL, Vitest (ESM tests).

Depends on: PR 1 (claude/barcodes-implementation-e46526, PR #518) โ€” this branch is stacked on it and needs SealedProductBarcode.

Spec: docs/superpowers/specs/2026-07-30-sealed-upc-from-tcgcsv-design.md ยง4

Global Constraints โ€‹

  • Server code is CommonJS; test files are ESM, co-located as x.test.js.
  • All Shopify API calls go through server/services/shopifyAPI.js (CLAUDE.md rule 6). Never raw axios/fetch to *.myshopify.com.
  • No fallback defaults for identity parameters โ€” game, shop, uuid (ยง5.5).
  • Regex character classes use [0-9], never \d.
  • Husky pre-commit runs ESLint; never --no-verify.
  • Commit messages: one imperative sentence stating the merchant-visible outcome.

Conventions this follows โ€‹

  • Dry-run by default, explicit flag to write โ€” matching server/scripts/dedupeShopifyProducts.js:18-20, the other Shopify-mutating script. (The Mongo-only migrations under scripts/migrations/ use the opposite --dry-run opt-in convention; the Shopify-touching precedent wins here because a wrong run mutates merchant storefronts, not just our own documents.)
  • --shop <domain> optional filter, same flag name as dedupeShopifyProducts.js.
  • Token access: decryptToken(store.accessToken) then new ShopifyAPI(shop, token), per server/scripts/dedupeShopifyProducts.js:129-130.

File Structure โ€‹

FileResponsibility
server/services/shopifyAPI.jsModify. Add getVariantBarcode(variantId) โ€” a focused read.
server/services/shopifyAPI.test.jsModify. Cover the new method.
server/scripts/migrations/repairSyntheticSealedBarcodes.jsCreate. Classifier + orchestration + guards.
server/scripts/migrations/repairSyntheticSealedBarcodes.test.jsCreate. Classifier and guard tests.
package.jsonModify. Add repair:sealed-barcodes and :apply scripts.

Why a new read method rather than extending getProductWithVariantsGraphQL: that query is on the sync hot path and runs for every product in every sync. Adding barcode there would pull an unused field on thousands of calls. A focused single-variant read keeps the hot path untouched. The productVariant(id:) root field is already proven in this file at server/services/shopifyAPI.js:810.


Task 1: getVariantBarcode on shopifyAPI โ€‹

Files:

  • Modify: server/services/shopifyAPI.js (add method near getVariantInventoryItemId, :1367)
  • Test: server/services/shopifyAPI.test.js (append a describe block)

Interfaces:

  • Consumes: nothing.

  • Produces: async getVariantBarcode(variantId: string): Promise<string|null> โ€” returns the trimmed barcode, or null when the variant is missing or the barcode is unset/blank.

  • [ ] Step 1: Write the failing test

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

javascript
describe('ShopifyAPI getVariantBarcode', () => {
    let api;

    beforeEach(() => {
        api = new ShopifyAPI('test-shop.myshopify.com', 'token');
    });

    it('returns the variant barcode', async () => {
        api.graphQL = vi.fn().mockResolvedValue({
            productVariant: { id: 'gid://shopify/ProductVariant/1', barcode: '195166278636' }
        });

        await expect(api.getVariantBarcode('gid://shopify/ProductVariant/1')).resolves.toBe('195166278636');
    });

    it('queries by the variant id', async () => {
        api.graphQL = vi.fn().mockResolvedValue({ productVariant: { barcode: '1' } });

        await api.getVariantBarcode('gid://shopify/ProductVariant/7');

        expect(api.graphQL).toHaveBeenCalledWith(expect.stringContaining('productVariant'), {
            id: 'gid://shopify/ProductVariant/7'
        });
    });

    it('returns null when the variant no longer exists', async () => {
        api.graphQL = vi.fn().mockResolvedValue({ productVariant: null });
        await expect(api.getVariantBarcode('gid://shopify/ProductVariant/1')).resolves.toBeNull();
    });

    it('returns null for an unset or blank barcode', async () => {
        api.graphQL = vi.fn().mockResolvedValue({ productVariant: { barcode: null } });
        await expect(api.getVariantBarcode('x')).resolves.toBeNull();

        api.graphQL = vi.fn().mockResolvedValue({ productVariant: { barcode: '   ' } });
        await expect(api.getVariantBarcode('x')).resolves.toBeNull();
    });
});
  • [ ] Step 2: Run to verify it fails
bash
npx vitest run server/services/shopifyAPI.test.js -t "getVariantBarcode"

Expected: FAIL โ€” api.getVariantBarcode is not a function.

  • [ ] Step 3: Write the implementation

Add to the ShopifyAPI class in server/services/shopifyAPI.js, immediately after getVariantInventoryItemId:

javascript
    /**
     * Read one variant's current barcode.
     *
     * Deliberately separate from getProductWithVariantsGraphQL: that query runs
     * for every product on every sync, and pulling an extra field there would
     * cost on thousands of calls for the benefit of one migration script.
     *
     * @param {string} variantId - Full GID format
     * @returns {Promise<string|null>} Trimmed barcode, or null if unset/missing
     */
    async getVariantBarcode(variantId) {
        const query = `
            query getVariantBarcode($id: ID!) {
                productVariant(id: $id) {
                    id
                    barcode
                }
            }
        `;

        const data = await this.graphQL(query, { id: variantId });
        const barcode = data.productVariant?.barcode;
        const trimmed = typeof barcode === 'string' ? barcode.trim() : '';
        return trimmed || null;
    }
  • [ ] Step 4: Run to verify it passes
bash
npx vitest run server/services/shopifyAPI.test.js -t "getVariantBarcode"

Expected: PASS โ€” 4 tests.

  • [ ] Step 5: Commit
bash
git add server/services/shopifyAPI.js server/services/shopifyAPI.test.js
git commit -m "Add a focused read for a single variant's barcode"

Task 2: The repair classifier โ€‹

Files:

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

Interfaces:

  • Consumes: BARCODE_PREFIX from server/utils/barcodeGenerator.js.
  • Produces: classifyBarcodeRepair(currentBarcode: string|null, knownUpc: string|null): { action: 'skip'|'set'|'clear', barcode?: string, reason?: string } โ€” exported as module.exports.classifyBarcodeRepair. Task 3 consumes it.

This is the safety-critical logic, so it is a pure function tested exhaustively rather than buried in the orchestration loop.

  • [ ] Step 1: Write the failing test

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

javascript
/**
 * Unit tests for the sealed barcode repair classifier.
 *
 * The safety property under test: we only ever modify a barcode we wrote
 * ourselves, identified by the LGSF prefix. Anything else โ€” a merchant's own
 * scan, a real UPC, an already-empty field โ€” is left alone.
 */

import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const { classifyBarcodeRepair } = require('./repairSyntheticSealedBarcodes.js');

describe('classifyBarcodeRepair', () => {
    it('replaces a synthetic barcode with the known UPC', () => {
        expect(classifyBarcodeRepair('LGSFMTGABCDEF012345', '195166278636')).toEqual({
            action: 'set',
            barcode: '195166278636',
        });
    });

    it('clears a synthetic barcode when no UPC is known', () => {
        expect(classifyBarcodeRepair('LGSFMTGABCDEF012345', null)).toEqual({
            action: 'clear',
            barcode: '',
        });
    });

    // The core safety property: a merchant who scanned the box themselves must
    // never have that value overwritten, even when we know a UPC.
    it('never touches a merchant-entered barcode', () => {
        expect(classifyBarcodeRepair('850001234567', '195166278636')).toEqual({
            action: 'skip',
            reason: 'merchant-value',
        });
    });

    it('leaves an already-empty barcode alone', () => {
        for (const empty of ['', '   ', null, undefined]) {
            expect(classifyBarcodeRepair(empty, '195166278636')).toEqual({
                action: 'skip',
                reason: 'already-empty',
            });
        }
    });

    it('trims before testing the prefix', () => {
        expect(classifyBarcodeRepair('  LGSFMTGABCDEF012345  ', '195166278636')).toEqual({
            action: 'set',
            barcode: '195166278636',
        });
    });

    // A value that merely contains LGSF is not one of ours โ€” only a prefix is.
    it('does not treat a mid-string LGSF as synthetic', () => {
        expect(classifyBarcodeRepair('123LGSF456789', null)).toEqual({
            action: 'skip',
            reason: 'merchant-value',
        });
    });

    it('recognises every game prefix', () => {
        for (const code of ['MTG', 'PKM', 'RFT']) {
            expect(classifyBarcodeRepair(`LGSF${code}ABCDEF012345`, null).action).toBe('clear');
        }
    });
});
  • [ ] Step 2: Run to verify it fails
bash
npx vitest run server/scripts/migrations/repairSyntheticSealedBarcodes.test.js

Expected: FAIL โ€” cannot find module.

  • [ ] Step 3: Write the minimal implementation

Create server/scripts/migrations/repairSyntheticSealedBarcodes.js with the header and classifier only (orchestration lands in Task 3):

javascript
#!/usr/bin/env node

/**
 * Repair sealed products already synced with a synthetic LGSFโ€ฆ barcode.
 *
 * Sealed products carry a real manufacturer UPC on the box. Before the sealed
 * UPC work, every synced sealed product got a generated LGSFโ€ฆ value instead,
 * which occupies Shopify's barcode field and makes a POS scan of the real code
 * miss. This replaces those with the real UPC where we now know one, and
 * clears them otherwise.
 *
 * NOT a one-time migration. Nothing re-pushes a sealed barcode after product
 * creation (createProduct is the only writer), so this script is the only
 * mechanism that can put a UPC on an already-synced sealed product. It is
 * idempotent and expected to be re-run as upstream coverage improves.
 *
 * Usage:
 *   node server/scripts/migrations/repairSyntheticSealedBarcodes.js                     # dry-run, all stores
 *   node server/scripts/migrations/repairSyntheticSealedBarcodes.js --shop x.myshopify.com
 *   node server/scripts/migrations/repairSyntheticSealedBarcodes.js --apply             # actually write
 *
 * Dry-run by default, matching dedupeShopifyProducts.js โ€” the other script
 * that mutates merchant storefronts rather than just our own documents.
 */

const { BARCODE_PREFIX } = require('../../utils/barcodeGenerator');

/**
 * Decide what to do with one variant's current barcode.
 *
 * Only values carrying our own BARCODE_PREFIX are eligible to change โ€” that
 * prefix is what makes this safe, because it identifies values we wrote. A
 * merchant's own scan is never modified.
 *
 * @param {string|null|undefined} currentBarcode - Live Shopify value
 * @param {string|null} knownUpc - Real UPC from SealedProductBarcode, if any
 * @returns {{action: 'skip'|'set'|'clear', barcode?: string, reason?: string}}
 */
function classifyBarcodeRepair(currentBarcode, knownUpc) {
    const current = typeof currentBarcode === 'string' ? currentBarcode.trim() : '';

    if (!current) {
        return { action: 'skip', reason: 'already-empty' };
    }
    if (!current.startsWith(BARCODE_PREFIX)) {
        return { action: 'skip', reason: 'merchant-value' };
    }
    if (knownUpc) {
        return { action: 'set', barcode: knownUpc };
    }
    return { action: 'clear', barcode: '' };
}

module.exports = { classifyBarcodeRepair };
  • [ ] Step 4: Run to verify it passes
bash
npx vitest run server/scripts/migrations/repairSyntheticSealedBarcodes.test.js

Expected: PASS โ€” 7 tests.

  • [ ] Step 5: Commit
bash
git add server/scripts/migrations/repairSyntheticSealedBarcodes.js server/scripts/migrations/repairSyntheticSealedBarcodes.test.js
git commit -m "Decide which sealed barcodes are ours to repair"

Task 3: Orchestration, guards, and npm scripts โ€‹

Files:

  • Modify: server/scripts/migrations/repairSyntheticSealedBarcodes.js
  • Modify: server/scripts/migrations/repairSyntheticSealedBarcodes.test.js
  • Modify: package.json

Interfaces:

  • Consumes: classifyBarcodeRepair (Task 2), getVariantBarcode (Task 1), SealedProductBarcode (PR 1).

  • Produces: parseArgs(argv: string[]): { shop: string|null, apply: boolean } and assertReferenceTablePopulated(count: number, apply: boolean): void, both exported for tests. main() stays the default export for the CLI.

  • [ ] Step 1: Write the failing tests

Append to server/scripts/migrations/repairSyntheticSealedBarcodes.test.js, extending the require at the top:

javascript
const { classifyBarcodeRepair, parseArgs, assertReferenceTablePopulated } = require('./repairSyntheticSealedBarcodes.js');
javascript
describe('parseArgs', () => {
    it('defaults to a dry run across all stores', () => {
        expect(parseArgs([])).toEqual({ shop: null, apply: false });
    });

    it('reads a shop filter', () => {
        expect(parseArgs(['--shop', 'x.myshopify.com'])).toEqual({ shop: 'x.myshopify.com', apply: false });
    });

    it('requires an explicit flag to write', () => {
        expect(parseArgs(['--apply'])).toEqual({ shop: null, apply: true });
        expect(parseArgs(['--shop', 'x.myshopify.com', '--apply'])).toEqual({
            shop: 'x.myshopify.com',
            apply: true,
        });
    });

    // A bare --shop with no value must not silently become a fleet-wide run.
    it('throws when --shop is given without a value', () => {
        expect(() => parseArgs(['--shop'])).toThrow(/--shop requires a value/);
    });
});

describe('assertReferenceTablePopulated', () => {
    // The run-ordering trap: against an empty reference table every lookup
    // misses, so every synthetic barcode is cleared โ€” and because nothing
    // re-pushes a sealed barcode, none of them can ever be refilled.
    it('refuses to apply when the reference table is empty', () => {
        expect(() => assertReferenceTablePopulated(0, true)).toThrow(/reference table is empty/i);
    });

    it('allows a dry run against an empty table', () => {
        expect(() => assertReferenceTablePopulated(0, false)).not.toThrow();
    });

    it('allows applying once the table is populated', () => {
        expect(() => assertReferenceTablePopulated(1, true)).not.toThrow();
    });
});
  • [ ] Step 2: Run to verify they fail
bash
npx vitest run server/scripts/migrations/repairSyntheticSealedBarcodes.test.js

Expected: FAIL โ€” parseArgs is not a function.

  • [ ] Step 3: Write the implementation

Add to repairSyntheticSealedBarcodes.js, above the existing module.exports:

javascript
/**
 * @param {string[]} argv - Arguments after the script name
 * @returns {{shop: string|null, apply: boolean}}
 */
function parseArgs(argv) {
    const args = { shop: null, apply: false };
    for (let i = 0; i < argv.length; i++) {
        if (argv[i] === '--shop') {
            const value = argv[i + 1];
            if (!value || value.startsWith('--')) {
                throw new Error('--shop requires a value');
            }
            args.shop = value;
            i++;
        } else if (argv[i] === '--apply') {
            args.apply = true;
        }
    }
    return args;
}

/**
 * Guard the run-ordering trap. Against an empty reference table every UPC
 * lookup misses, so every synthetic barcode would be cleared โ€” and since
 * nothing re-pushes a sealed barcode after creation, they could never be
 * refilled. Run the sealed price importer first.
 *
 * @param {number} count - Documents in SealedProductBarcode
 * @param {boolean} apply - Whether this run will write
 */
function assertReferenceTablePopulated(count, apply) {
    if (apply && count === 0) {
        throw new Error(
            'SealedProductBarcode reference table is empty โ€” run the sealed price '
            + 'importer (npm run data:update-sealed-prices) before applying, or this '
            + 'would clear every synthetic barcode with nothing able to restore them.'
        );
    }
}

Then the orchestration:

javascript
async function run() {
    require('dotenv').config();
    const mongoose = require('mongoose');
    const logger = require('../../utils/logger');
    const Store = require('../../models/Store');
    const SealedProduct = require('../../models/SealedProduct');
    const SealedProductBarcode = require('../../models/SealedProductBarcode');
    const ShopifyAPI = require('../../services/shopifyAPI');
    const { decryptToken } = require('../../utils/crypto');

    const { shop, apply } = parseArgs(process.argv.slice(2));

    const mongoUri = process.env.MONGODB_URI;
    if (!mongoUri) throw new Error('MONGODB_URI not configured');
    await mongoose.connect(mongoUri);

    const referenceCount = await SealedProductBarcode.estimatedDocumentCount();
    assertReferenceTablePopulated(referenceCount, apply);

    logger.info('Sealed barcode repair starting', {
        mode: apply ? 'APPLY' : 'DRY-RUN', shop: shop || 'all stores', referenceCount
    });

    const query = { shopifyVariantId: { $exists: true, $ne: null } };
    if (shop) query.shop = shop;

    const products = await SealedProduct.find(query)
        .select('shop game uuid name shopifyProductId shopifyVariantId')
        .lean();

    const byShop = new Map();
    for (const product of products) {
        if (!byShop.has(product.shop)) byShop.set(product.shop, []);
        byShop.get(product.shop).push(product);
    }

    const totals = { set: 0, cleared: 0, skipped: 0, failed: 0, storesSkipped: 0 };

    for (const [shopDomain, shopProducts] of byShop) {
        const store = await Store.findOne({ shop: shopDomain });
        if (!store?.accessToken || store.isActive === false) {
            logger.warn('Skipping store without a usable token', { shop: shopDomain });
            totals.storesSkipped++;
            continue;
        }

        let api;
        try {
            api = new ShopifyAPI(shopDomain, decryptToken(store.accessToken));
        } catch (err) {
            logger.warn('Skipping store whose token could not be decrypted', {
                shop: shopDomain, error: err.message
            });
            totals.storesSkipped++;
            continue;
        }

        for (const product of shopProducts) {
            try {
                const current = await api.getVariantBarcode(product.shopifyVariantId);
                const knownUpc = await SealedProductBarcode.getBarcode(product.game, product.uuid);
                const decision = classifyBarcodeRepair(current, knownUpc);

                if (decision.action === 'skip') {
                    totals.skipped++;
                    continue;
                }

                logger.info(`${apply ? 'Repairing' : 'Would repair'} sealed barcode`, {
                    shop: shopDomain, name: product.name, from: current, to: decision.barcode
                });

                if (apply) {
                    await api.updateVariantGraphQL(
                        product.shopifyVariantId,
                        { barcode: decision.barcode },
                        product.shopifyProductId
                    );
                }

                if (decision.action === 'set') totals.set++;
                else totals.cleared++;
            } catch (err) {
                logger.error('Failed to repair sealed barcode', {
                    shop: shopDomain, uuid: product.uuid, error: err.message
                });
                totals.failed++;
            }
        }
    }

    logger.info('Sealed barcode repair complete', { mode: apply ? 'APPLY' : 'DRY-RUN', ...totals });
    await mongoose.disconnect();
    return totals;
}

Replace the export line with:

javascript
module.exports = run;
module.exports.classifyBarcodeRepair = classifyBarcodeRepair;
module.exports.parseArgs = parseArgs;
module.exports.assertReferenceTablePopulated = assertReferenceTablePopulated;

if (require.main === module) {
    run()
        .then(() => process.exit(0))
        .catch((err) => {
            console.error(`โŒ ${err.message}`);
            process.exit(1);
        });
}

Note on the test import: Task 2's test destructures { classifyBarcodeRepair }. Changing module.exports to the run function with named properties keeps that destructure working, because named properties are still attached. No test edit is needed.

  • [ ] Step 4: Run to verify they pass
bash
npx vitest run server/scripts/migrations/repairSyntheticSealedBarcodes.test.js

Expected: PASS โ€” 14 tests (7 classifier + 4 parseArgs + 3 guard).

  • [ ] Step 5: Add the npm scripts

In package.json, alongside the other migrate entries:

json
    "repair:sealed-barcodes": "node server/scripts/migrations/repairSyntheticSealedBarcodes.js",
    "repair:sealed-barcodes:apply": "node server/scripts/migrations/repairSyntheticSealedBarcodes.js --apply",

Note the inversion versus the migrate:*:dry-run pairs: here the bare script is the safe one and :apply is the dangerous one, because this script mutates merchant storefronts.

  • [ ] Step 6: Commit
bash
git add server/scripts/migrations/repairSyntheticSealedBarcodes.js server/scripts/migrations/repairSyntheticSealedBarcodes.test.js package.json
git commit -m "Repair sealed products still carrying a synthetic barcode"

Task 4: Full verification โ€‹

  • [ ] Step 1: Full server test suite
bash
npm test

Expected: exit 0, zero failures. Baseline entering this PR is 117 files / 2131 passed; this adds 18 tests.

  • [ ] Step 2: Lint
bash
npm run lint

Expected: exit 0. Warning count must equal the pre-change baseline of 437.

  • [ ] Step 3: Coverage
bash
npm run test:coverage

Expected: exit 0, no threshold errors. server/scripts/** is excluded from coverage by vitest.config.js:36, so the script itself will not appear in the per-file table โ€” its logic is covered by the classifier and guard tests regardless. Check shopifyAPI.js did not regress.

  • [ ] Step 4: Confirm no raw Shopify calls were introduced (rule 6)
bash
grep -n "myshopify.com" server/scripts/migrations/repairSyntheticSealedBarcodes.js

Expected: no output โ€” the shop domain arrives via --shop/Mongo and every call goes through ShopifyAPI.

  • [ ] Step 5: Verify the dry-run default holds
bash
node -e "const m=require('./server/scripts/migrations/repairSyntheticSealedBarcodes.js'); console.log(JSON.stringify(m.parseArgs([])))"

Expected: {"shop":null,"apply":false}.


PR description checklist โ€‹

  • Stacked on PR #518; must merge after it.
  • Dry-run by default; --apply required to write. Precedent: dedupeShopifyProducts.js:18-20.
  • The LGSF prefix is the safety boundary โ€” merchant-entered barcodes are never modified, asserted by a dedicated test.
  • Hard guard aborts --apply against an empty reference table (the run-ordering trap), enforced in code rather than documentation.
  • Rule 6: every Shopify call goes through shopifyAPI.js.
  • Not a one-time migration โ€” it is the only mechanism that can put a UPC on an already-synced sealed product, so it is idempotent and re-runnable.
  • Operational note: run npm run data:update-sealed-prices first, then npm run repair:sealed-barcodes to preview, then :apply.

Follow-on work (not this PR) โ€‹

  • A sealed re-sync path, so newly-learned UPCs reach already-synced products without a manual script run.
  • Deleting the redundant SealedProduct.barcode field, contingent on confirming it is empty in production.
  • Cherry-picking c644924 (drop its sealed barcodesEnabled gate on the way in).