TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { categoryFamily } from '@rareindex/taxonomy';2import { normalizeForMatch, type AssetAttributes } from '@rareindex/shared';34/**5 * Deterministic canonical key per category family (§112). Two descriptions of the same object6 * from different sources must produce the same key when their structured attributes agree.7 * Unknown parts are encoded as '' so partial records still collide with fuller ones only when8 * the discriminating fields match.9 */10const n = (s: string | null | undefined): string => (s ? normalizeForMatch(s).replace(/\s+/g, ' ') : '');11const num = (s: string | null | undefined): string => (s ? s.toLowerCase().replace(/^#/, '').replace(/\s+/g, '') : '');1213/** Identifier keys considered deterministic enough to match on their own. */14export const DETERMINISTIC_IDS = [15 'scryfall_id', 'oracle_id', 'tcgplayer_id', 'cardmarket_id', 'pokemontcg_id', 'ygo_id', 'ygo_set_code', 'psa_spec_id', 'psa_item_id',16 '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',17 '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',18 // 2026-09-08 wave — product-level ids only. Item-level ids (lot numbers, listing ids, slab cert numbers) are19 // deliberately NOT here: certs live in `certificates`, listings/sales are keyed by (source, externalId).20 'limitless_card', 'yuyutei_card', 'hareruya_product_id', 'cardrush_product_id', 'cardtrader_blueprint_id',21 'pick_number', 'ngc_coin_id', 'numista_id', 'km_number', 'sg_number', 'whisky_auction_product', 'gcd_issue_id', 'gamevaluenow_id',22] as const;2324export function deterministicIdentifiers(ids: Record<string, string> | undefined): Record<string, string> {25 const out: Record<string, string> = {};26 if (!ids) return out;27 for (const k of DETERMINISTIC_IDS) {28 const v = ids[k];29 if (v && String(v).trim()) out[k] = String(v).trim();30 }31 return out;32}3334export function buildCanonicalKey(a: AssetAttributes): string {35 const family = categoryFamily(a.categorySlug);36 const cat = a.categorySlug;37 switch (family) {38 case 'trading_cards':39 case 'sports_cards':40 case 'non_sport_cards': {41 const set = n(a.setCode) || n(a.set);42 return [cat, set, num(a.number), n(a.name), n(a.variant), n(a.language) || 'en'].join('|');43 }44 case 'watches':45 return [cat, n(a.brand), n(a.reference) || n(a.model), n(a.variant)].join('|');46 case 'sneakers': {47 const style = a.identifiers?.style_code ?? a.identifiers?.sku;48 return style ? [cat, n(style)].join('|') : [cat, n(a.brand), n(a.model) || n(a.name), n(a.color), a.year ?? ''].join('|');49 }50 case 'lego': {51 const setNo = a.identifiers?.lego_set_number ?? a.number ?? a.model;52 return [cat, setNo ? num(setNo) : n(a.name), n(a.variant)].join('|');53 }54 case 'video_games':55 case 'gaming_hardware':56 return [cat, n(a.identifiers?.platform ?? a.series ?? a.brand), n(a.name), n(a.region) || n(a.language), n(a.variant)].join('|');57 case 'comics':58 case 'manga':59 return [cat, n(a.brand), n(a.series) || n(a.name), num(a.number), n(a.variant), a.year ?? ''].join('|');60 case 'funko':61 case 'designer_toys':62 case 'action_figures':63 case 'vintage_toys':64 return [cat, n(a.brand), n(a.series) || n(a.franchise), num(a.number), n(a.name), n(a.variant)].join('|');65 case 'coins':66 case 'banknotes':67 return [cat, n(a.country), a.year ?? '', n(a.name), n(a.variant) || n(a.identifiers?.mint_mark)].join('|');68 case 'wine':69 case 'whisky':70 case 'rum':71 case 'cognac':72 return [cat, n(a.brand), n(a.name), a.year ?? '', n(a.size)].join('|');73 default:74 return [cat, n(a.brand), n(a.name), a.year ?? '', n(a.variant), n(a.reference) || num(a.number)].join('|');75 }76}7778/** Human title used for slugs, search and display. Deterministic from attributes. */79export function composeTitle(a: AssetAttributes): string {80 const family = categoryFamily(a.categorySlug);81 const parts: string[] = [];82 switch (family) {83 case 'trading_cards':84 case 'sports_cards':85 case 'non_sport_cards': {86 let head = a.name;87 if (a.number) head += ` #${a.number.replace(/^#/, '')}`;88 const tail = [a.set, a.edition, a.variant].filter(Boolean).join(' ');89 parts.push(head);90 if (tail) parts.push(tail);91 if (a.year) parts.push(`(${a.year})`);92 break;93 }94 case 'watches':95 parts.push([a.brand, a.model ?? a.name].filter(Boolean).join(' '));96 if (a.reference) parts.push(a.reference);97 if (a.variant) parts.push(a.variant);98 break;99 case 'sneakers':100 parts.push(a.name);101 if (a.color && !a.name.toLowerCase().includes(a.color.toLowerCase())) parts.push(a.color);102 if (a.identifiers?.style_code) parts.push(a.identifiers.style_code);103 break;104 case 'lego': {105 const setNo = a.identifiers?.lego_set_number ?? a.number;106 parts.push([setNo ? `LEGO ${setNo}` : 'LEGO', a.name].filter(Boolean).join(' '));107 if (a.series) parts.push(a.series);108 if (a.year) parts.push(`(${a.year})`);109 break;110 }111 case 'video_games':112 case 'gaming_hardware':113 parts.push(a.name);114 if (a.identifiers?.platform ?? a.series) parts.push(a.identifiers?.platform ?? a.series ?? '');115 if (a.region) parts.push(a.region);116 break;117 case 'comics':118 case 'manga':119 parts.push([a.series ?? a.name, a.number ? `#${a.number}` : null].filter(Boolean).join(' '));120 if (a.variant) parts.push(a.variant);121 if (a.year) parts.push(`(${a.year})`);122 break;123 default:124 parts.push([a.brand, a.name].filter(Boolean).join(' '));125 if (a.variant) parts.push(a.variant);126 if (a.year) parts.push(`(${a.year})`);127 }128 return parts.filter(Boolean).join(' · ').replace(/\s+/g, ' ').trim();129}130