import { z } from 'zod'; import { SUPPORTED_CURRENCIES } from './money.js'; /** * Canonical Zod schemas. These are the contracts between connectors, the ingestion pipeline, * the database layer, and the apps. Source-specific quirks must be resolved BEFORE data * reaches these shapes (see CLAUDE.md §108, §196.6). */ export const CurrencySchema = z.enum(SUPPORTED_CURRENCIES); export const SourceTypeSchema = z.enum([ 'marketplace', 'auction_house', 'dealer', 'pricing_guide', 'grading_company', 'manufacturer', 'museum', 'archive', 'catalog', 'collector_database', 'community', 'forum', 'news', 'analytics_provider', 'registry', 'official_source', ]); export type SourceType = z.infer; export const EngineSchema = z.enum(['api', 'feed', 'firecrawl', 'scrapfly', 'browser', 'manual']); export type Engine = z.infer; export const RecordKindSchema = z.enum(['sale', 'listing', 'auction_lot', 'price_observation', 'catalog_item', 'population_report', 'news_item']); export type RecordKind = z.infer; /** Immutable raw payload captured from a source. Never mutated after insert (§108). */ export const RawRecordSchema = z.object({ connectorId: z.string(), sourceId: z.string(), engine: EngineSchema, url: z.string().url().or(z.string().min(1)), externalId: z.string().nullable().default(null), kind: RecordKindSchema, fetchedAt: z.coerce.date(), contentHash: z.string(), httpStatus: z.number().int().nullable().default(null), payload: z.unknown(), /** raw HTML/markdown snapshot key in object storage or inline text (bounded) */ snapshotRef: z.string().nullable().default(null), parserVersion: z.string(), connectorVersion: z.string(), }); export type RawRecord = z.infer; /** Attributes used for canonical asset identity; anything unknown must be null, never guessed (§192). */ export const AssetAttributesSchema = z.object({ categorySlug: z.string(), subcategorySlug: z.string().nullable().default(null), franchise: z.string().nullable().default(null), brand: z.string().nullable().default(null), series: z.string().nullable().default(null), set: z.string().nullable().default(null), setCode: z.string().nullable().default(null), name: z.string(), model: z.string().nullable().default(null), reference: z.string().nullable().default(null), number: z.string().nullable().default(null), year: z.number().int().nullable().default(null), edition: z.string().nullable().default(null), variant: z.string().nullable().default(null), language: z.string().nullable().default(null), region: z.string().nullable().default(null), country: z.string().nullable().default(null), material: z.string().nullable().default(null), size: z.string().nullable().default(null), color: z.string().nullable().default(null), rarity: z.string().nullable().default(null), productionQuantity: z.number().int().nullable().default(null), originalMsrp: z.number().nullable().default(null), originalMsrpCurrency: CurrencySchema.nullable().default(null), /** Deterministic identifiers: upc, isbn, sku, scryfall_id, tcgplayer_id, bricklink_id, style_code, ref, cert… */ identifiers: z.record(z.string(), z.string()).default({}), metadata: z.record(z.string(), z.unknown()).default({}), }); export type AssetAttributes = z.infer; export const GradeSchema = z.object({ grader: z.string().nullable().default(null), // PSA, BGS, CGC, SGC, TAG, WATA, VGA, PCGS, NGC, CBCS, raw grade: z.string().nullable().default(null), // "10", "9.5", "9.8", "MS65" qualifier: z.string().nullable().default(null), // "OC", "Signature Series", "Black Label" certificationNumber: z.string().nullable().default(null), }); export type Grade = z.infer; export const ConditionSchema = z.object({ /** category-specific normalised condition slug (see taxonomy condition scales) */ condition: z.string().nullable().default(null), conditionRaw: z.string().nullable().default(null), completeness: z.string().nullable().default(null), // sealed, cib, loose, box_papers, papers_only… }); const BaseNormalized = z.object({ connectorId: z.string(), sourceId: z.string(), sourceUrl: z.string(), externalId: z.string().nullable().default(null), rawTitle: z.string(), description: z.string().nullable().default(null), imageUrls: z.array(z.string()).default([]), attributes: AssetAttributesSchema, grade: GradeSchema.prefault({}), condition: ConditionSchema.prefault({}), observedAt: z.coerce.date(), /** 0–1 confidence in the extraction & identification */ confidence: z.number().min(0).max(1).default(0.8), parserVersion: z.string(), }); export const NormalizedSaleSchema = BaseNormalized.extend({ kind: z.literal('sale'), saleType: z.enum(['auction', 'fixed_price', 'best_offer', 'private', 'dealer', 'unknown']).default('unknown'), saleDate: z.coerce.date(), price: z.number().nonnegative(), currency: CurrencySchema, buyerPremiumIncluded: z.boolean().nullable().default(null), quantity: z.number().int().positive().default(1), isBundle: z.boolean().default(false), location: z.string().nullable().default(null), auctionHouse: z.string().nullable().default(null), lotNumber: z.string().nullable().default(null), }); export type NormalizedSale = z.infer; export const NormalizedListingSchema = BaseNormalized.extend({ kind: z.literal('listing'), listingType: z.enum(['fixed_price', 'auction', 'best_offer', 'ask', 'unknown']).default('unknown'), price: z.number().nonnegative().nullable(), currency: CurrencySchema.nullable(), seller: z.string().nullable().default(null), sellerReputation: z.string().nullable().default(null), location: z.string().nullable().default(null), shippingCost: z.number().nullable().default(null), quantity: z.number().int().nullable().default(null), listedAt: z.coerce.date().nullable().default(null), endsAt: z.coerce.date().nullable().default(null), availability: z.enum(['available', 'sold', 'ended', 'removed', 'unknown']).default('available'), bidCount: z.number().int().nullable().default(null), }); export type NormalizedListing = z.infer; export const NormalizedAuctionLotSchema = BaseNormalized.extend({ kind: z.literal('auction_lot'), auctionHouse: z.string(), auctionName: z.string().nullable().default(null), lotNumber: z.string().nullable().default(null), startsAt: z.coerce.date().nullable().default(null), endsAt: z.coerce.date().nullable().default(null), estimateLow: z.number().nullable().default(null), estimateHigh: z.number().nullable().default(null), currentBid: z.number().nullable().default(null), currency: CurrencySchema.nullable(), status: z.enum(['upcoming', 'live', 'ended', 'unknown']).default('unknown'), location: z.string().nullable().default(null), }); export type NormalizedAuctionLot = z.infer; /** Price-guide style observation (e.g. Scryfall market price). Not a transaction (§111 spirit). */ export const NormalizedPriceObservationSchema = BaseNormalized.extend({ kind: z.literal('price_observation'), priceKind: z.enum(['market', 'low', 'mid', 'high', 'trend', 'average_7d', 'average_30d', 'last_sale_reported', 'guide_value']), price: z.number().nonnegative(), currency: CurrencySchema, observationDate: z.coerce.date(), sampleSize: z.number().int().nullable().default(null), }); export type NormalizedPriceObservation = z.infer; /** Catalog entry from a manufacturer/reference database: creates or enriches canonical assets. */ export const NormalizedCatalogItemSchema = BaseNormalized.extend({ kind: z.literal('catalog_item'), releaseDate: z.coerce.date().nullable().default(null), }); export type NormalizedCatalogItem = z.infer; export const NormalizedPopulationReportSchema = z.object({ kind: z.literal('population_report'), connectorId: z.string(), sourceId: z.string(), sourceUrl: z.string(), grader: z.string(), attributes: AssetAttributesSchema, reportDate: z.coerce.date(), total: z.number().int().nonnegative(), byGrade: z.record(z.string(), z.number().int().nonnegative()), parserVersion: z.string(), confidence: z.number().min(0).max(1).default(0.9), }); export type NormalizedPopulationReport = z.infer; export const NormalizedNewsItemSchema = z.object({ kind: z.literal('news_item'), connectorId: z.string(), sourceId: z.string(), sourceUrl: z.string(), title: z.string(), summary: z.string().nullable().default(null), publishedAt: z.coerce.date().nullable().default(null), categorySlugs: z.array(z.string()).default([]), imageUrl: z.string().nullable().default(null), parserVersion: z.string(), }); export type NormalizedNewsItem = z.infer; export const NormalizedRecordSchema = z.discriminatedUnion('kind', [ NormalizedSaleSchema, NormalizedListingSchema, NormalizedAuctionLotSchema, NormalizedPriceObservationSchema, NormalizedCatalogItemSchema, NormalizedPopulationReportSchema, NormalizedNewsItemSchema, ]); export type NormalizedRecord = z.infer; export const ConnectorStatusSchema = z.enum(['healthy', 'degraded', 'failing', 'paused', 'disabled', 'maintenance', 'unknown']); export type ConnectorStatus = z.infer; /** Operator-facing labels (SPEC §12): UP · DEGRADED · BROKEN · DISABLED · MAINTENANCE. */ export const HEALTH_LABELS: Record = { healthy: 'UP', degraded: 'DEGRADED', failing: 'BROKEN', paused: 'DISABLED', disabled: 'DISABLED', maintenance: 'MAINTENANCE', unknown: 'UNKNOWN', }; export const ConnectorHealthSchema = z.object({ connector: z.string(), status: ConnectorStatusSchema, success_rate_24h: z.number().min(0).max(1).nullable(), pages_attempted: z.number().int().nonnegative(), pages_success: z.number().int().nonnegative(), firecrawl_success_rate: z.number().min(0).max(1).nullable(), scrapfly_fallback_rate: z.number().min(0).max(1).nullable(), parse_failure_rate: z.number().min(0).max(1).nullable(), last_success: z.string().nullable(), last_error: z.string().nullable(), schema_version: z.string(), records_24h: z.number().int().nonnegative(), duplicates_24h: z.number().int().nonnegative(), anomalies: z.array(z.string()).default([]), /** SPEC §12 additions (optional so older snapshots still validate) */ requests_24h: z.number().int().nonnegative().optional(), latency_ms_avg: z.number().nullable().optional(), http_errors: z.record(z.string(), z.number()).optional(), challenges_24h: z.number().int().nonnegative().optional(), circuit_refusals_24h: z.number().int().nonnegative().optional(), rate_limited_24h: z.number().int().nonnegative().optional(), /** average raw records/day over the trailing 7 days (baseline for result-count collapse) */ records_7d_daily_avg: z.number().nullable().optional(), schema_drift: z.array(z.string()).optional(), /** ISO of the newest source-side date ingested (sale/listing date), i.e. data freshness */ data_freshness: z.string().nullable().optional(), /** environment variables missing for a gated connector */ missing_requirements: z.array(z.string()).optional(), }); export type ConnectorHealth = z.infer; export const ExtractionResultSchema = z.object({ success: z.boolean(), engine: EngineSchema, url: z.string(), finalUrl: z.string().nullable().default(null), httpStatus: z.number().int().nullable().default(null), html: z.string().nullable().default(null), markdown: z.string().nullable().default(null), json: z.unknown().nullable().default(null), /** raw bytes for binary documents (PDF catalogues, gzip feeds) — never persisted in the DB */ buffer: z.custom((v) => v instanceof Uint8Array).nullable().optional(), qualityScore: z.number().min(0).max(1).default(0), requiresReview: z.boolean().default(false), error: z.string().nullable().default(null), costCredits: z.number().nullable().default(null), durationMs: z.number().nullable().default(null), fetchedAt: z.coerce.date(), }); export type ExtractionResult = z.infer;