Appearance
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 underscripts/migrations/use the opposite--dry-runopt-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 asdedupeShopifyProducts.js.- Token access:
decryptToken(store.accessToken)thennew ShopifyAPI(shop, token), perserver/scripts/dedupeShopifyProducts.js:129-130.
File Structure โ
| File | Responsibility |
|---|---|
server/services/shopifyAPI.js | Modify. Add getVariantBarcode(variantId) โ a focused read. |
server/services/shopifyAPI.test.js | Modify. Cover the new method. |
server/scripts/migrations/repairSyntheticSealedBarcodes.js | Create. Classifier + orchestration + guards. |
server/scripts/migrations/repairSyntheticSealedBarcodes.test.js | Create. Classifier and guard tests. |
package.json | Modify. 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 neargetVariantInventoryItemId,: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, ornullwhen 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_PREFIXfromserver/utils/barcodeGenerator.js. - Produces:
classifyBarcodeRepair(currentBarcode: string|null, knownUpc: string|null): { action: 'skip'|'set'|'clear', barcode?: string, reason?: string }โ exported asmodule.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.jsExpected: 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.jsExpected: 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 }andassertReferenceTablePopulated(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.jsExpected: 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.jsExpected: 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 testExpected: exit 0, zero failures. Baseline entering this PR is 117 files / 2131 passed; this adds 18 tests.
- [ ] Step 2: Lint
bash
npm run lintExpected: exit 0. Warning count must equal the pre-change baseline of 437.
- [ ] Step 3: Coverage
bash
npm run test:coverageExpected: 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.jsExpected: 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;
--applyrequired to write. Precedent:dedupeShopifyProducts.js:18-20. - The
LGSFprefix is the safety boundary โ merchant-entered barcodes are never modified, asserted by a dedicated test. - Hard guard aborts
--applyagainst 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-pricesfirst, thennpm run repair:sealed-barcodesto 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.barcodefield, contingent on confirming it is empty in production. - Cherry-picking
c644924(drop its sealedbarcodesEnabledgate on the way in).
