1import { z } from 'zod';2import { SUPPORTED_CURRENCIES } from './money.js';34/**5 * Canonical Zod schemas. These are the contracts between connectors, the ingestion pipeline,6 * the database layer, and the apps. Source-specific quirks must be resolved BEFORE data7 * reaches these shapes (see CLAUDE.md §108, §196.6).8 */910export const CurrencySchema = z.enum(SUPPORTED_CURRENCIES);1112export const SourceTypeSchema = z.enum([13 'marketplace', 'auction_house', 'dealer', 'pricing_guide', 'grading_company', 'manufacturer', 'museum', 'archive',14 'catalog', 'collector_database', 'community', 'forum', 'news', 'analytics_provider', 'registry', 'official_source',15]);16export type SourceType = z.infer<typeof SourceTypeSchema>;1718export const EngineSchema = z.enum(['api', 'feed', 'firecrawl', 'scrapfly', 'browser', 'manual']);19export type Engine = z.infer<typeof EngineSchema>;2021export const RecordKindSchema = z.enum(['sale', 'listing', 'auction_lot', 'price_observation', 'catalog_item', 'population_report', 'news_item']);22export type RecordKind = z.infer<typeof RecordKindSchema>;2324/** Immutable raw payload captured from a source. Never mutated after insert (§108). */25export const RawRecordSchema = z.object({26 connectorId: z.string(),27 sourceId: z.string(),28 engine: EngineSchema,29 url: z.string().url().or(z.string().min(1)),30 externalId: z.string().nullable().default(null),31 kind: RecordKindSchema,32 fetchedAt: z.coerce.date(),33 contentHash: z.string(),34 httpStatus: z.number().int().nullable().default(null),35 payload: z.unknown(),36 /** raw HTML/markdown snapshot key in object storage or inline text (bounded) */37 snapshotRef: z.string().nullable().default(null),38 parserVersion: z.string(),39 connectorVersion: z.string(),40});41export type RawRecord = z.infer<typeof RawRecordSchema>;4243/** Attributes used for canonical asset identity; anything unknown must be null, never guessed (§192). */44export const AssetAttributesSchema = z.object({45 categorySlug: z.string(),46 subcategorySlug: z.string().nullable().default(null),47 franchise: z.string().nullable().default(null),48 brand: z.string().nullable().default(null),49 series: z.string().nullable().default(null),50 set: z.string().nullable().default(null),51 setCode: z.string().nullable().default(null),52 name: z.string(),53 model: z.string().nullable().default(null),54 reference: z.string().nullable().default(null),55 number: z.string().nullable().default(null),56 year: z.number().int().nullable().default(null),57 edition: z.string().nullable().default(null),58 variant: z.string().nullable().default(null),59 language: z.string().nullable().default(null),60 region: z.string().nullable().default(null),61 country: z.string().nullable().default(null),62 material: z.string().nullable().default(null),63 size: z.string().nullable().default(null),64 color: z.string().nullable().default(null),65 rarity: z.string().nullable().default(null),66 productionQuantity: z.number().int().nullable().default(null),67 originalMsrp: z.number().nullable().default(null),68 originalMsrpCurrency: CurrencySchema.nullable().default(null),69 /** Deterministic identifiers: upc, isbn, sku, scryfall_id, tcgplayer_id, bricklink_id, style_code, ref, cert… */70 identifiers: z.record(z.string(), z.string()).default({}),71 metadata: z.record(z.string(), z.unknown()).default({}),72});73export type AssetAttributes = z.infer<typeof AssetAttributesSchema>;7475export const GradeSchema = z.object({76 grader: z.string().nullable().default(null), // PSA, BGS, CGC, SGC, TAG, WATA, VGA, PCGS, NGC, CBCS, raw77 grade: z.string().nullable().default(null), // "10", "9.5", "9.8", "MS65"78 qualifier: z.string().nullable().default(null), // "OC", "Signature Series", "Black Label"79 certificationNumber: z.string().nullable().default(null),80});81export type Grade = z.infer<typeof GradeSchema>;8283export const ConditionSchema = z.object({84 /** category-specific normalised condition slug (see taxonomy condition scales) */85 condition: z.string().nullable().default(null),86 conditionRaw: z.string().nullable().default(null),87 completeness: z.string().nullable().default(null), // sealed, cib, loose, box_papers, papers_only…88});8990const BaseNormalized = z.object({91 connectorId: z.string(),92 sourceId: z.string(),93 sourceUrl: z.string(),94 externalId: z.string().nullable().default(null),95 rawTitle: z.string(),96 description: z.string().nullable().default(null),97 imageUrls: z.array(z.string()).default([]),98 attributes: AssetAttributesSchema,99 grade: GradeSchema.prefault({}),100 condition: ConditionSchema.prefault({}),101 observedAt: z.coerce.date(),102 /** 0–1 confidence in the extraction & identification */103 confidence: z.number().min(0).max(1).default(0.8),104 parserVersion: z.string(),105});106107export const NormalizedSaleSchema = BaseNormalized.extend({108 kind: z.literal('sale'),109 saleType: z.enum(['auction', 'fixed_price', 'best_offer', 'private', 'dealer', 'unknown']).default('unknown'),110 saleDate: z.coerce.date(),111 price: z.number().nonnegative(),112 currency: CurrencySchema,113 buyerPremiumIncluded: z.boolean().nullable().default(null),114 quantity: z.number().int().positive().default(1),115 isBundle: z.boolean().default(false),116 location: z.string().nullable().default(null),117 auctionHouse: z.string().nullable().default(null),118 lotNumber: z.string().nullable().default(null),119});120export type NormalizedSale = z.infer<typeof NormalizedSaleSchema>;121122export const NormalizedListingSchema = BaseNormalized.extend({123 kind: z.literal('listing'),124 listingType: z.enum(['fixed_price', 'auction', 'best_offer', 'ask', 'unknown']).default('unknown'),125 price: z.number().nonnegative().nullable(),126 currency: CurrencySchema.nullable(),127 seller: z.string().nullable().default(null),128 sellerReputation: z.string().nullable().default(null),129 location: z.string().nullable().default(null),130 shippingCost: z.number().nullable().default(null),131 quantity: z.number().int().nullable().default(null),132 listedAt: z.coerce.date().nullable().default(null),133 endsAt: z.coerce.date().nullable().default(null),134 availability: z.enum(['available', 'sold', 'ended', 'removed', 'unknown']).default('available'),135 bidCount: z.number().int().nullable().default(null),136});137export type NormalizedListing = z.infer<typeof NormalizedListingSchema>;138139export const NormalizedAuctionLotSchema = BaseNormalized.extend({140 kind: z.literal('auction_lot'),141 auctionHouse: z.string(),142 auctionName: z.string().nullable().default(null),143 lotNumber: z.string().nullable().default(null),144 startsAt: z.coerce.date().nullable().default(null),145 endsAt: z.coerce.date().nullable().default(null),146 estimateLow: z.number().nullable().default(null),147 estimateHigh: z.number().nullable().default(null),148 currentBid: z.number().nullable().default(null),149 currency: CurrencySchema.nullable(),150 status: z.enum(['upcoming', 'live', 'ended', 'unknown']).default('unknown'),151 location: z.string().nullable().default(null),152});153export type NormalizedAuctionLot = z.infer<typeof NormalizedAuctionLotSchema>;154155/** Price-guide style observation (e.g. Scryfall market price). Not a transaction (§111 spirit). */156export const NormalizedPriceObservationSchema = BaseNormalized.extend({157 kind: z.literal('price_observation'),158 priceKind: z.enum(['market', 'low', 'mid', 'high', 'trend', 'average_7d', 'average_30d', 'last_sale_reported', 'guide_value']),159 price: z.number().nonnegative(),160 currency: CurrencySchema,161 observationDate: z.coerce.date(),162 sampleSize: z.number().int().nullable().default(null),163});164export type NormalizedPriceObservation = z.infer<typeof NormalizedPriceObservationSchema>;165166/** Catalog entry from a manufacturer/reference database: creates or enriches canonical assets. */167export const NormalizedCatalogItemSchema = BaseNormalized.extend({168 kind: z.literal('catalog_item'),169 releaseDate: z.coerce.date().nullable().default(null),170});171export type NormalizedCatalogItem = z.infer<typeof NormalizedCatalogItemSchema>;172173export const NormalizedPopulationReportSchema = z.object({174 kind: z.literal('population_report'),175 connectorId: z.string(),176 sourceId: z.string(),177 sourceUrl: z.string(),178 grader: z.string(),179 attributes: AssetAttributesSchema,180 reportDate: z.coerce.date(),181 total: z.number().int().nonnegative(),182 byGrade: z.record(z.string(), z.number().int().nonnegative()),183 parserVersion: z.string(),184 confidence: z.number().min(0).max(1).default(0.9),185});186export type NormalizedPopulationReport = z.infer<typeof NormalizedPopulationReportSchema>;187188export const NormalizedNewsItemSchema = z.object({189 kind: z.literal('news_item'),190 connectorId: z.string(),191 sourceId: z.string(),192 sourceUrl: z.string(),193 title: z.string(),194 summary: z.string().nullable().default(null),195 publishedAt: z.coerce.date().nullable().default(null),196 categorySlugs: z.array(z.string()).default([]),197 imageUrl: z.string().nullable().default(null),198 parserVersion: z.string(),199});200export type NormalizedNewsItem = z.infer<typeof NormalizedNewsItemSchema>;201202export const NormalizedRecordSchema = z.discriminatedUnion('kind', [203 NormalizedSaleSchema,204 NormalizedListingSchema,205 NormalizedAuctionLotSchema,206 NormalizedPriceObservationSchema,207 NormalizedCatalogItemSchema,208 NormalizedPopulationReportSchema,209 NormalizedNewsItemSchema,210]);211export type NormalizedRecord = z.infer<typeof NormalizedRecordSchema>;212213export const ConnectorStatusSchema = z.enum(['healthy', 'degraded', 'failing', 'paused', 'unknown']);214export const ConnectorHealthSchema = z.object({215 connector: z.string(),216 status: ConnectorStatusSchema,217 success_rate_24h: z.number().min(0).max(1).nullable(),218 pages_attempted: z.number().int().nonnegative(),219 pages_success: z.number().int().nonnegative(),220 firecrawl_success_rate: z.number().min(0).max(1).nullable(),221 scrapfly_fallback_rate: z.number().min(0).max(1).nullable(),222 parse_failure_rate: z.number().min(0).max(1).nullable(),223 last_success: z.string().nullable(),224 last_error: z.string().nullable(),225 schema_version: z.string(),226 records_24h: z.number().int().nonnegative(),227 duplicates_24h: z.number().int().nonnegative(),228 anomalies: z.array(z.string()).default([]),229});230export type ConnectorHealth = z.infer<typeof ConnectorHealthSchema>;231232export const ExtractionResultSchema = z.object({233 success: z.boolean(),234 engine: EngineSchema,235 url: z.string(),236 finalUrl: z.string().nullable().default(null),237 httpStatus: z.number().int().nullable().default(null),238 html: z.string().nullable().default(null),239 markdown: z.string().nullable().default(null),240 json: z.unknown().nullable().default(null),241 qualityScore: z.number().min(0).max(1).default(0),242 requiresReview: z.boolean().default(false),243 error: z.string().nullable().default(null),244 costCredits: z.number().nullable().default(null),245 durationMs: z.number().nullable().default(null),246 fetchedAt: z.coerce.date(),247});248export type ExtractionResult = z.infer<typeof ExtractionResultSchema>;249