// Schema declarations and the DefaultSchema consensus shape. // // A Schema maps a producer's private source JSON to the platform-wide // Projection. Field names point at keys in the source map; Value coercions // happen in the Builder. A Schema is the unit of configuration per tenant; // the Registry (registry.go) carries the per-tenant map. package catalog // UpdatedAtSource controls where the Projection's UpdatedAt comes from. // // - StampAtPublish — Builder sets UpdatedAt to time.Now().UnixMilli() at the // moment Build() runs. RESERVED as a deliberate-fallback for sources // that genuinely have no other version stamp. NOT a content-version // address: a full snapshot rebuild re-stamps every projection and // invalidates every consumer cache even when nothing changed. Use only // when the producer side has NO source mtime / hash to set. // // - UseSourceField — the production-correct choice. Builder parses // source[Schema.UpdatedAtField] as a Unix-millisecond integer (or // decimal-fraction-of-Unix-second if the source uses seconds — Apply // accepts that and converts). This is the content-version value; // rebuilds produce identical UpdatedAt when content is identical. // // Critical contract: UpdatedAt is the CONTENT VERSION, not the publish // timestamp. Consumers use it for snapshot + revalidate. If your schema // sets UpdatedAtSource=StampAtPublish, every snapshot rebuild churns the // version. Choose UseSourceField for any consumer-cache scenario. type UpdatedAtSource int const ( // StampAtPublish sets UpdatedAt at Build() time. DefaultSchema // deliberately does NOT use this; it's reserved for callers who know // they want publish-time semantics. StampAtPublish UpdatedAtSource = iota // UseSourceField reads the version from Schema.UpdatedAtField. This is // the contract for content-versioned snapshots. UseSourceField ) // Schema is the declarative per-tenant mapping from a producer's source JSON // to the published Projection. Zero value is NOT valid — always start from // DefaultSchema() and override what differs, so unset-but-required fields // surface immediately rather than silently emitting empty Projections. // // All "Field" strings refer to keys in the source map[string]any that the // producer hands the Builder. Empty Field means "this Projection carrier is // supplied by Fixed* / Default* / Build-time, not read from source". type Schema struct { // Tenant is the registry key this schema is registered under. Empty // means "default" — see Registry. Tenant string `json:"tenant,omitempty"` // --- source field references (per carrier) --- SKUField string `json:"sku_field"` TitleField string `json:"title_field,omitempty"` PriceField string `json:"price_field"` CurrencyField string `json:"currency_field,omitempty"` TaxClassField string `json:"tax_class_field,omitempty"` InventoryTrackedF string `json:"inventory_tracked_field,omitempty"` DropShipF string `json:"drop_ship_field,omitempty"` CategoryField string `json:"category_field,omitempty"` Category2Field string `json:"category2_field,omitempty"` Category3Field string `json:"category3_field,omitempty"` Category4Field string `json:"category4_field,omitempty"` Category5Field string `json:"category5_field,omitempty"` BrandField string `json:"brand_field,omitempty"` ImageField string `json:"image_field,omitempty"` // AttributesFields names the keys in the source map[string]any that // should be extracted and built into the Projection's json.RawMessage Attributes field. AttributesFields []string `json:"attributes_fields,omitempty"` // ItemIdField is the source-side key carrying the producer's catalog // item id (uint32-equivalent). DefaultSchema points at "id" (matching // the product service's /api/get/{sku} shape). Producers that omit // an integer id field simply leave ItemIdField empty — consumers see // Projection.ItemID=0 and fall back to their HTTP lookup. ItemIdField string `json:"item_id_field,omitempty"` // --- fixed / default values used when a source field is absent ---. FixedCurrency string `json:"fixed_currency,omitempty"` // DefaultTaxClass is what the Builder writes when the source has no // TaxClass field (e.g. producer doesn't classify). Empty means "leave // empty" — consumer's tax.Provider gets called with empty string. DefaultTaxClass string `json:"default_tax_class,omitempty"` // --- derived-value controls --- // PriceScale: source value interpreted as MINOR units if ≤ 1 (pass // through; floats round half-away-from-zero, ints pass through exact), // MAJOR units if 100 (multiply by 100 to get øre), etc. Negative = // rejected. The Builder rounds half-away-from-zero on fractional // results so the integer Projection never silently truncates. PriceScale int `json:"price_scale"` // IDPrefix is prepended to the normalised ID. Use "sku:" for the // consensus convention; an empty prefix means raw SKU. IDPrefix string `json:"id_prefix,omitempty"` // UpdatedAtSource — see type doc. DefaultSchema uses UseSourceField and // pins UpdatedAtField="updated_at". Producers MUST supply an updated_at // value (Unix-ms int); Apply returns ErrProjectionBuild (wrapped) when // it's missing rather than silently stamp-now'ing and churning every // rebuild. StampAtPublish is RESERVED for sources with no other stamp. UpdatedAtSource UpdatedAtSource `json:"updated_at_source"` // UpdatedAtField is the SOURCE-side key (when UpdatedAtSource == // UseSourceField) the Builder reads the version from. UpdatedAtField string `json:"updated_at_field,omitempty"` // TrueValues is the set of source values that map to InventoryTracked // / DropShip = true. Comparison is lower-cased. Defaults to // DefaultTrueValues below if left nil — never add "false" / "0" etc, // the Builder treats anything not in TrueValues as false. TrueValues []string `json:"true_values,omitempty"` } // DefaultSchema is the consensus schema: SKU under "sku", price under // "price_inc_vat" already in øre, currency from source "currency" or empty, // tax_class from "tax_class", inventory_tracked / drop_ship from // "inventory_tracked" / "drop_ship" bool fields. updated_at MUST come from // the source under the field name "updated_at" (Unix-ms) — Apply rejects // missing values loudly rather than silent stamp-now churn. // // Use this when a tenant has no override file; it is the safe default for // producers that already emit platform-shaped JSON. func DefaultSchema() Schema { return Schema{ Tenant: "default", // source-side field names SKUField: "sku", TitleField: "title", PriceField: "price_inc_vat", CurrencyField: "currency", TaxClassField: "tax_class", InventoryTrackedF: "inventory_tracked", DropShipF: "drop_ship", CategoryField: "category", Category2Field: "category2", Category3Field: "category3", Category4Field: "category4", Category5Field: "category5", BrandField: "brand", ImageField: "image", // Standard product service carries the integer catalog id under "id". ItemIdField: "id", UpdatedAtField: "updated_at", AttributesFields: []string{"data"}, FixedCurrency: "", // read from source if present DefaultTaxClass: "standard", PriceScale: 1, // price_inc_vat already in minor units IDPrefix: "sku:", UpdatedAtSource: UseSourceField, // change to StampAtPublish only with intent TrueValues: DefaultTrueValues(), } } // BygglagretSchema is an explicit preset for the Swedish building-supplies // tenant that ships inc-VAT float prices in a "PriceInclVAT" string field. // // CRITICAL: TaxClassField (VATClass) is a STRING CLASS IDENTIFIER, not a raw // percent. The producer MUST pre-classify numeric VAT codes ("25" → // "standard", "12" → "reduced-12", "6" → "reduced-6") before publishing. // The wire contract is class identifiers; platform/tax resolves them to // numeric rates out of band. Shipping "25" as the class would break that // resolution path on the consumer side. // // ItemIdField: bygglagret rows carry the integer item id under "item_id" // (matches the per-row log shape we see from the upstream product import). func BygglagretSchema() Schema { return Schema{ Tenant: "bygglagret", SKUField: "ArticleNumber", TitleField: "Name", PriceField: "PriceInclVAT", CurrencyField: "", // fixed below TaxClassField: "VATClass", InventoryTrackedF: "StockTracked", DropShipF: "DropshipFlag", CategoryField: "CategoryPath", Category2Field: "Category2", Category3Field: "Category3", Category4Field: "Category4", Category5Field: "Category5", BrandField: "Brand", ImageField: "PrimaryImage", ItemIdField: "item_id", UpdatedAtField: "LastModifiedMs", AttributesFields: []string{"data"}, FixedCurrency: "SEK", DefaultTaxClass: "standard", PriceScale: 100, // PriceInclVAT is in SEK; multiply for øre IDPrefix: "sku:", UpdatedAtSource: UseSourceField, TrueValues: DefaultTrueValues(), } } // DefaultTrueValues is the consensus truthy-string set for bool-typed // source fields. Lower-cased comparison in Builder. func DefaultTrueValues() []string { return []string{"true", "yes", "1", "on"} }