Ship Pokemon and Lorcana bulk import libs/scripts, unified weekly cron sync across MTG/Pokemon/Lorcana with per-game error isolation and catalog_sync_log telemetry. Admin UI adds Unified/Incremental/Bulk MTG modes. - Rename reconcile migrations to 1781442330* timestamps so they apply after bulk-data migrations without node-pg-migrate ordering conflicts - Add Lorcana set-code normalization + orphan cleanup migrations - Drop stricter user_cards_user_card_unique (keep 3-column foil unique) - Update docs/SCHEMA_MAP.md for tags, card_tags, catalog_sync_log, bulk columns Co-authored-by: Cursor <cursoragent@cursor.com>
215 lines
5.7 KiB
JavaScript
215 lines
5.7 KiB
JavaScript
/**
|
|
* Bulk Pokémon catalog sync from pokemon-tcg-data GitHub JSON.
|
|
*
|
|
* Fetches all sets from sets/en.json, then upserts every card in each set
|
|
* (keyed on scryfall_id = external card id). Populates legalities from set metadata.
|
|
*/
|
|
|
|
import { sql } from '@vercel/postgres';
|
|
|
|
import {
|
|
fetchPokemonSetCards,
|
|
fetchPokemonSets,
|
|
mapGithubCardForInsert,
|
|
} from './pokemon-github.js';
|
|
|
|
const DEFAULT_BATCH_SIZE = 100;
|
|
const DEFAULT_SET_DELAY_MS = 100;
|
|
|
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
function getSetLegalities(setMeta) {
|
|
if (setMeta?.legalities && typeof setMeta.legalities === 'object') {
|
|
return setMeta.legalities;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
export function mapPokemonBulkRow(mapped, setLegalities) {
|
|
return {
|
|
scryfallId: mapped.externalId,
|
|
name: mapped.name,
|
|
setName: mapped.setName,
|
|
setCode: mapped.setCode,
|
|
cardNumber: mapped.cardNumber,
|
|
rarity: mapped.rarity,
|
|
cardType: mapped.cardType,
|
|
colors: JSON.stringify(mapped.types || []),
|
|
oracleText: mapped.flavorText,
|
|
power: mapped.hp,
|
|
imageUrl: mapped.imageSmall,
|
|
stockImageUrl: mapped.imageLarge,
|
|
legalities: JSON.stringify(setLegalities),
|
|
};
|
|
}
|
|
|
|
function buildUpsertQuery(batch) {
|
|
const columns = [
|
|
'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game',
|
|
'mana_cost', 'cmc', 'card_type', 'colors', 'oracle_text', 'power', 'toughness',
|
|
'image_url', 'stock_image_url', 'current_price', 'market_price', 'legalities', 'verified',
|
|
];
|
|
|
|
const placeholders = [];
|
|
const values = [];
|
|
let paramIdx = 1;
|
|
|
|
for (const row of batch) {
|
|
const rowPlaceholders = [];
|
|
const rowValues = [
|
|
row.scryfallId,
|
|
row.name,
|
|
row.setName,
|
|
row.setCode,
|
|
row.cardNumber,
|
|
row.rarity,
|
|
'Pokemon',
|
|
null,
|
|
null,
|
|
row.cardType,
|
|
row.colors,
|
|
row.oracleText,
|
|
row.power,
|
|
null,
|
|
row.imageUrl,
|
|
row.stockImageUrl,
|
|
null,
|
|
null,
|
|
row.legalities,
|
|
true,
|
|
];
|
|
|
|
for (let i = 0; i < rowValues.length; i += 1) {
|
|
rowPlaceholders.push(`$${paramIdx}`);
|
|
paramIdx += 1;
|
|
}
|
|
placeholders.push(`(${rowPlaceholders.join(', ')})`);
|
|
values.push(...rowValues);
|
|
}
|
|
|
|
const updateCols = columns
|
|
.filter((c) => c !== 'scryfall_id')
|
|
.map((c) => `${c} = EXCLUDED.${c}`)
|
|
.join(', ');
|
|
|
|
const query = `
|
|
INSERT INTO cards (${columns.join(', ')})
|
|
VALUES ${placeholders.join(',\n')}
|
|
ON CONFLICT (scryfall_id) DO UPDATE SET
|
|
${updateCols},
|
|
updated_at = CURRENT_TIMESTAMP
|
|
`;
|
|
|
|
return { query, values };
|
|
}
|
|
|
|
async function upsertBatch(batch, queryFn, stats) {
|
|
if (batch.length === 0) return;
|
|
|
|
if (stats.dryRun) {
|
|
stats.skipped += batch.length;
|
|
return;
|
|
}
|
|
|
|
const { query, values } = buildUpsertQuery(batch);
|
|
try {
|
|
await queryFn(query, values);
|
|
stats.upserted += batch.length;
|
|
} catch (error) {
|
|
console.error(`[pokemon-bulk] Batch failed (${batch.length} rows):`, error.message);
|
|
for (const row of batch) {
|
|
try {
|
|
const { query: singleQ, values: singleV } = buildUpsertQuery([row]);
|
|
await queryFn(singleQ, singleV);
|
|
stats.upserted += 1;
|
|
} catch (singleErr) {
|
|
console.error(`[pokemon-bulk] Failed: ${row.name} (${row.scryfallId}):`, singleErr.message);
|
|
stats.errors += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function upsertSetCards(setMeta, cards, options, stats) {
|
|
const setLegalities = getSetLegalities(setMeta);
|
|
let batch = [];
|
|
|
|
for (const card of cards) {
|
|
const mapped = mapGithubCardForInsert(card, setMeta);
|
|
batch.push(mapPokemonBulkRow(mapped, setLegalities));
|
|
|
|
if (batch.length >= options.batchSize) {
|
|
await upsertBatch(batch, options.queryFn, stats);
|
|
batch = [];
|
|
}
|
|
}
|
|
|
|
if (batch.length > 0) {
|
|
await upsertBatch(batch, options.queryFn, stats);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run a full Pokémon bulk sync from pokemon-tcg-data GitHub.
|
|
* Returns a summary compatible with multi-game catalog sync orchestration.
|
|
*/
|
|
export async function runBulkPokemonSync(options = {}) {
|
|
const batchSize = options.batchSize ?? parseInt(process.env.BATCH_SIZE || String(DEFAULT_BATCH_SIZE), 10);
|
|
const dryRun = options.dryRun ?? process.env.DRY_RUN === 'true';
|
|
const setDelayMs = options.setDelayMs ?? DEFAULT_SET_DELAY_MS;
|
|
const queryFn = options.query ?? ((query, values) => sql.query(query, values));
|
|
|
|
const sets = await fetchPokemonSets();
|
|
console.log(`[pokemon-bulk] Found ${sets.length} sets in pokemon-tcg-data`);
|
|
|
|
const stats = {
|
|
dryRun,
|
|
upserted: 0,
|
|
skipped: 0,
|
|
errors: 0,
|
|
setsProcessed: 0,
|
|
};
|
|
|
|
for (let index = 0; index < sets.length; index += 1) {
|
|
const setMeta = sets[index];
|
|
const setCode = setMeta.id;
|
|
|
|
if (!setCode) {
|
|
console.warn('[pokemon-bulk] Skipping set with no id');
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const cards = await fetchPokemonSetCards(setCode);
|
|
stats.setsProcessed += 1;
|
|
|
|
if (cards.length === 0) {
|
|
console.log(`[pokemon-bulk] Set ${setCode}: no cards (empty or missing file)`);
|
|
} else {
|
|
await upsertSetCards(setMeta, cards, { batchSize, queryFn }, stats);
|
|
console.log(`[pokemon-bulk] Set ${setCode}: processed ${cards.length} cards`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`[pokemon-bulk] Set ${setCode} failed:`, error.message);
|
|
stats.errors += 1;
|
|
}
|
|
|
|
if (index < sets.length - 1) {
|
|
await delay(setDelayMs);
|
|
}
|
|
}
|
|
|
|
const summary = {
|
|
mode: 'bulk',
|
|
game: 'pokemon',
|
|
setsProcessed: stats.setsProcessed,
|
|
totalSets: sets.length,
|
|
upserted: stats.upserted,
|
|
skipped: stats.skipped,
|
|
errors: stats.errors,
|
|
dryRun,
|
|
};
|
|
|
|
console.log('[pokemon-bulk]', JSON.stringify(summary));
|
|
return summary;
|
|
}
|