diff --git a/catalog-emit b/catalog-emit new file mode 100755 index 0000000..89dc757 Binary files /dev/null and b/catalog-emit differ diff --git a/catalog/builder.go b/catalog/builder.go index 74478f8..96e3883 100644 --- a/catalog/builder.go +++ b/catalog/builder.go @@ -116,6 +116,7 @@ func Apply(s Schema, source map[string]any) (Projection, error) { } p := Projection{ + ItemID: uint32Of(source, s.ItemIdField), ID: id, SKU: sku, Title: stringOf(source, s.TitleField), @@ -180,6 +181,63 @@ func stringOf(m map[string]any, key string) string { return fmt.Sprintf("%v", v) } +// uint32Of returns the value at key as a uint32. Best-effort coercion: +// json.Number / int / int64 / float (integral check) / truthy numeric +// string. Returns 0 for any missing / non-uint32-representable value — +// Projection.ItemID = 0 is the documented "no id" signal that the cart's +// overlay path uses to fall back to PRODUCT_BASE_URL. +func uint32Of(m map[string]any, key string) uint32 { + if key == "" { + return 0 + } + v, ok := m[key] + if !ok || v == nil { + return 0 + } + switch x := v.(type) { + case uint32: + return x + case uint64: + if x > 0xFFFFFFFF { + return 0 // overflow – treat as unset, downstream falls back + } + return uint32(x) + case int: + if x < 0 || x > 0xFFFFFFFF { + return 0 + } + return uint32(x) + case int64: + if x < 0 || x > 0xFFFFFFFF { + return 0 + } + return uint32(x) + case float64: + if x != float64(uint32(x)) || x < 0 || x > 0xFFFFFFFF { + return 0 // fractional or out-of-range + } + return uint32(x) + case json.Number: + s := x.String() + n, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0 + } + return uint32(n) + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0 + } + n, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0 + } + return uint32(n) + } + return 0 +} + // boolOf returns the value at key coerced through truthy. nil/missing keys // are false; strings lower-cased and matched against truthy; bools passed // through; numerics "!= 0"; json.Number goes through Float64. diff --git a/catalog/projection.go b/catalog/projection.go index d334ce8..3daf519 100644 --- a/catalog/projection.go +++ b/catalog/projection.go @@ -9,6 +9,7 @@ // Mapping source JSON → Projection is per-tenant and lives in Schema. // The consensus mapping for DefaultSchema() is: // +// item_id ← int coercion of source[ItemIdField] (omitempty; 0 = unset) // id ← IDPrefix + SKU (e.g. "sku:") // sku ← source[SKUField] // title ← source[TitleField] (omitempty, optional) @@ -48,8 +49,17 @@ import ( // // Field rationale: // -// ID — opaque published identity; SKU alone is not a stable -// cross-module id (different scopes reuse it). +// ItemID — opaque numeric catalog item id from the producer's +// source (e.g. product service `/api/get/{sku}` integer +// `id` field, or the bygglagret raw row `item_id`). +// Carried here as uint32 to mirror go-cart-actor's +// proto messages.AddItem.ItemId which dedupes cart lines +// on equality. Zero means "producer didn't publish" — a +// legitimate value when the source has no integer id +// (the cart fall-back path uses HTTP PRODUCT_BASE_URL +// to recover the id in that case). +// ID — opaque published string identity; SKU alone is not a +// stable cross-module id (different scopes reuse it). // SKU — domain identifier; the join key cart already uses. // PriceIncVat — display + checkout projection; NOT transactional truth. // System of record for the total is settled via the @@ -66,10 +76,16 @@ import ( // tracking for non-stock items. // Category / Brand / Image — display projection only. // UpdatedAt — version stamp for snapshot + revalidate. +// +// WIRE-COMPAT: adding a field is forward+backward-safe. Old snapshot +// readers ignore unknown JSON keys; new readers populate the field from +// the JSON token. The on-disk snapshot binary header/record layout is +// unchanged \u2014 only the per-blob JSON payload gains a key. type Projection struct { - ID string `json:"id"` - SKU string `json:"sku"` - Title string `json:"title,omitempty"` + ItemID uint32 `json:"item_id,omitempty"` + ID string `json:"id"` + SKU string `json:"sku"` + Title string `json:"title,omitempty"` PriceIncVat money.Cents `json:"price_inc_vat"` Currency string `json:"currency,omitempty"` // TaxClass uses a string identifier (see platform/tax class constants). diff --git a/catalog/schema.go b/catalog/schema.go index c7631dd..d5bc3a0 100644 --- a/catalog/schema.go +++ b/catalog/schema.go @@ -61,6 +61,12 @@ type Schema struct { CategoryField string `json:"category_field,omitempty"` BrandField string `json:"brand_field,omitempty"` ImageField string `json:"image_field,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"` @@ -118,6 +124,8 @@ func DefaultSchema() Schema { CategoryField: "category", BrandField: "brand", ImageField: "image", + // Standard product service carries the integer catalog id under "id". + ItemIdField: "id", UpdatedAtField: "updated_at", FixedCurrency: "", // read from source if present @@ -139,6 +147,9 @@ func DefaultSchema() Schema { // 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", @@ -152,6 +163,7 @@ func BygglagretSchema() Schema { CategoryField: "CategoryPath", BrandField: "Brand", ImageField: "PrimaryImage", + ItemIdField: "item_id", UpdatedAtField: "LastModifiedMs", FixedCurrency: "SEK",