import { categoryFamily } from '@rareindex/taxonomy'; import { normalizeForMatch, type AssetAttributes } from '@rareindex/shared'; /** * Deterministic canonical key per category family (§112). Two descriptions of the same object * from different sources must produce the same key when their structured attributes agree. * Unknown parts are encoded as '' so partial records still collide with fuller ones only when * the discriminating fields match. */ const n = (s: string | null | undefined): string => (s ? normalizeForMatch(s).replace(/\s+/g, ' ') : ''); const num = (s: string | null | undefined): string => (s ? s.toLowerCase().replace(/^#/, '').replace(/\s+/g, '') : ''); /** Identifier keys considered deterministic enough to match on their own. */ export const DETERMINISTIC_IDS = [ 'scryfall_id', 'oracle_id', 'tcgplayer_id', 'cardmarket_id', 'pokemontcg_id', 'ygo_id', 'ygo_set_code', 'psa_spec_id', 'psa_item_id', 'style_code', 'sku', 'upc', 'ean', 'isbn', 'jan', 'lego_set_number', 'bricklink_id', 'brickset_id', 'pricecharting_id', 'reference', 'goldin_item_id', 'stockx_id', 'chrono24_id', 'comic_id', 'gocollect_id', 'hobbydb_id', 'funko_id', 'tcgdex_id', 'lorcast_id', 'mtggoldfish_uuid', 'gatcg_edition_id', 'swudb_id', 'digimoncard_id', 'sorcery_printing_id', 'discogs_release_id', 'pcgs_number', 'mfc_id', 'comics_org_id', 'ebay_epid', 'asin', 'vin', 'bgg_id', 'amiami_gcode', 'fossilera_item', 'ppg_id', // 2026-09-08 wave — product-level ids only. Item-level ids (lot numbers, listing ids, slab cert numbers) are // deliberately NOT here: certs live in `certificates`, listings/sales are keyed by (source, externalId). 'limitless_card', 'yuyutei_card', 'hareruya_product_id', 'cardrush_product_id', 'cardtrader_blueprint_id', 'pick_number', 'ngc_coin_id', 'numista_id', 'km_number', 'sg_number', 'whisky_auction_product', 'gcd_issue_id', 'gamevaluenow_id', ] as const; export function deterministicIdentifiers(ids: Record | undefined): Record { const out: Record = {}; if (!ids) return out; for (const k of DETERMINISTIC_IDS) { const v = ids[k]; if (v && String(v).trim()) out[k] = String(v).trim(); } return out; } export function buildCanonicalKey(a: AssetAttributes): string { const family = categoryFamily(a.categorySlug); const cat = a.categorySlug; switch (family) { case 'trading_cards': case 'sports_cards': case 'non_sport_cards': { const set = n(a.setCode) || n(a.set); return [cat, set, num(a.number), n(a.name), n(a.variant), n(a.language) || 'en'].join('|'); } case 'watches': return [cat, n(a.brand), n(a.reference) || n(a.model), n(a.variant)].join('|'); case 'sneakers': { const style = a.identifiers?.style_code ?? a.identifiers?.sku; return style ? [cat, n(style)].join('|') : [cat, n(a.brand), n(a.model) || n(a.name), n(a.color), a.year ?? ''].join('|'); } case 'lego': { const setNo = a.identifiers?.lego_set_number ?? a.number ?? a.model; return [cat, setNo ? num(setNo) : n(a.name), n(a.variant)].join('|'); } case 'video_games': case 'gaming_hardware': return [cat, n(a.identifiers?.platform ?? a.series ?? a.brand), n(a.name), n(a.region) || n(a.language), n(a.variant)].join('|'); case 'comics': case 'manga': return [cat, n(a.brand), n(a.series) || n(a.name), num(a.number), n(a.variant), a.year ?? ''].join('|'); case 'funko': case 'designer_toys': case 'action_figures': case 'vintage_toys': return [cat, n(a.brand), n(a.series) || n(a.franchise), num(a.number), n(a.name), n(a.variant)].join('|'); case 'coins': case 'banknotes': return [cat, n(a.country), a.year ?? '', n(a.name), n(a.variant) || n(a.identifiers?.mint_mark)].join('|'); case 'wine': case 'whisky': case 'rum': case 'cognac': return [cat, n(a.brand), n(a.name), a.year ?? '', n(a.size)].join('|'); default: return [cat, n(a.brand), n(a.name), a.year ?? '', n(a.variant), n(a.reference) || num(a.number)].join('|'); } } /** Human title used for slugs, search and display. Deterministic from attributes. */ export function composeTitle(a: AssetAttributes): string { const family = categoryFamily(a.categorySlug); const parts: string[] = []; switch (family) { case 'trading_cards': case 'sports_cards': case 'non_sport_cards': { let head = a.name; if (a.number) head += ` #${a.number.replace(/^#/, '')}`; const tail = [a.set, a.edition, a.variant].filter(Boolean).join(' '); parts.push(head); if (tail) parts.push(tail); if (a.year) parts.push(`(${a.year})`); break; } case 'watches': parts.push([a.brand, a.model ?? a.name].filter(Boolean).join(' ')); if (a.reference) parts.push(a.reference); if (a.variant) parts.push(a.variant); break; case 'sneakers': parts.push(a.name); if (a.color && !a.name.toLowerCase().includes(a.color.toLowerCase())) parts.push(a.color); if (a.identifiers?.style_code) parts.push(a.identifiers.style_code); break; case 'lego': { const setNo = a.identifiers?.lego_set_number ?? a.number; parts.push([setNo ? `LEGO ${setNo}` : 'LEGO', a.name].filter(Boolean).join(' ')); if (a.series) parts.push(a.series); if (a.year) parts.push(`(${a.year})`); break; } case 'video_games': case 'gaming_hardware': parts.push(a.name); if (a.identifiers?.platform ?? a.series) parts.push(a.identifiers?.platform ?? a.series ?? ''); if (a.region) parts.push(a.region); break; case 'comics': case 'manga': parts.push([a.series ?? a.name, a.number ? `#${a.number}` : null].filter(Boolean).join(' ')); if (a.variant) parts.push(a.variant); if (a.year) parts.push(`(${a.year})`); break; default: parts.push([a.brand, a.name].filter(Boolean).join(' ')); if (a.variant) parts.push(a.variant); if (a.year) parts.push(`(${a.year})`); } return parts.filter(Boolean).join(' · ').replace(/\s+/g, ' ').trim(); }