From 21bcb6344033b8cf5829a6a98368de26ae6344d6 Mon Sep 17 00:00:00 2001 From: matst80 Date: Sun, 28 Jun 2026 17:52:49 +0200 Subject: [PATCH] platform --- Dockerfile | 73 ++++ catalog/builder.go | 386 ++++++++++++++++++++ catalog/builder_test.go | 162 +++++++++ catalog/doc.go | 41 +++ catalog/projection.go | 134 +++++++ catalog/registry.go | 144 ++++++++ catalog/registry_test.go | 107 ++++++ catalog/schema.go | 171 +++++++++ catalog/schema_test.go | 96 +++++ catalog/snapshot.go | 423 ++++++++++++++++++++++ catalog/snapshot_helpers_test.go | 22 ++ catalog/snapshot_manager.go | 201 +++++++++++ catalog/snapshot_manager_test.go | 177 +++++++++ catalog/snapshot_other.go | 17 + catalog/snapshot_test.go | 448 +++++++++++++++++++++++ catalog/snapshot_unix.go | 61 ++++ cmd/catalog-emit/main.go | 571 ++++++++++++++++++++++++++++++ cmd/event-emit-publisher/main.go | 314 ++++++++++++++++ cmd/internal/catalogtest/items.go | 138 ++++++++ event/event.go | 95 +++++ go.mod | 5 + go.sum | 6 + inventory/inventory.go | 66 ++++ inventory/inventory_test.go | 47 +++ mcp/jsonrpc.go | 47 +++ mcp/schema.go | 55 +++ mcp/server.go | 181 ++++++++++ mcp/tool.go | 51 +++ mcp/ucp.go | 40 +++ money/money.go | 143 ++++++++ money/money_test.go | 129 +++++++ order/order.go | 53 +++ order/order_test.go | 30 ++ rabbit/conn.go | 265 ++++++++++++++ rabbit/conn_test.go | 258 ++++++++++++++ rabbit/topic.go | 64 ++++ seeds/catalog/schemas.json | 36 ++ tax/nordic.go | 56 +++ tax/price.go | 175 +++++++++ tax/tax.go | 62 ++++ tax/tax_test.go | 223 ++++++++++++ uid/uid.go | 130 +++++++ 42 files changed, 5903 insertions(+) create mode 100644 Dockerfile create mode 100644 catalog/builder.go create mode 100644 catalog/builder_test.go create mode 100644 catalog/doc.go create mode 100644 catalog/projection.go create mode 100644 catalog/registry.go create mode 100644 catalog/registry_test.go create mode 100644 catalog/schema.go create mode 100644 catalog/schema_test.go create mode 100644 catalog/snapshot.go create mode 100644 catalog/snapshot_helpers_test.go create mode 100644 catalog/snapshot_manager.go create mode 100644 catalog/snapshot_manager_test.go create mode 100644 catalog/snapshot_other.go create mode 100644 catalog/snapshot_test.go create mode 100644 catalog/snapshot_unix.go create mode 100644 cmd/catalog-emit/main.go create mode 100644 cmd/event-emit-publisher/main.go create mode 100644 cmd/internal/catalogtest/items.go create mode 100644 event/event.go create mode 100644 go.sum create mode 100644 inventory/inventory.go create mode 100644 inventory/inventory_test.go create mode 100644 mcp/jsonrpc.go create mode 100644 mcp/schema.go create mode 100644 mcp/server.go create mode 100644 mcp/tool.go create mode 100644 mcp/ucp.go create mode 100644 money/money.go create mode 100644 money/money_test.go create mode 100644 order/order.go create mode 100644 order/order_test.go create mode 100644 rabbit/conn.go create mode 100644 rabbit/conn_test.go create mode 100644 rabbit/topic.go create mode 100644 seeds/catalog/schemas.json create mode 100644 tax/nordic.go create mode 100644 tax/price.go create mode 100644 tax/tax.go create mode 100644 tax/tax_test.go create mode 100644 uid/uid.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c28ae56 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +# Multi-binary image for the platform/ catalog wire harnesses. +# +# Build context is the platform/ directory. Modules used by the binaries all +# live under platform/, so a single-tree build is sufficient — no external +# replace directives needed. +# +# Pure Go: cmd/catalog-emit and cmd/event-emit-publisher use only stdlib + +# github.com/rabbitmq/amqp091-go + git.k6n.net/mats/platform/* internals. +# No CGO, so we ship a Debian-bookworm-slim runtime with ca-certificates +# and a timezone DB. The writer (slask-finder) is NOT built here — it lives +# in its own Dockerfile and has its own ONNX / libtokenizers deps (CGO). + +# ─── 1. Build stage ──────────────────────────────────────────────────────── +FROM golang:1.26-bookworm AS build-stage +WORKDIR /app +ENV CGO_ENABLED=0 GOFLAGS="-trimpath" + +# Module files first so the dep download caches correctly on rebuild. +COPY go.mod go.sum ./ +RUN go mod download + +# Rest of the source. +COPY . . + +ARG TARGETARCH +ARG BUILDTIME +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -o /out/catalog-emit ./cmd/catalog-emit && \ + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ + go build -ldflags="-s -w" -o /out/event-emit-publisher ./cmd/event-emit-publisher + +# ─── 2. Runtime stage ──────────────────────────────────────────────────── +FROM debian:bookworm-slim + +# ca-certificates for TLS-validated RabbitMQ URLs (amqps://). +# tzdata so time.Now() resolves zones predictably in logs. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + tzdata && \ + rm -rf /var/lib/apt/lists/* + +# Non-root user, matching the k8s securityContext pattern that +# 20-data-plane.yaml uses for cart/checkout/profile (runAsUser: 1000). +# Pick 1010 here to leave 1000 available for future platform pods. +RUN groupadd --gid 1010 platcat && \ + useradd --uid 1010 --gid platcat \ + --home-dir /home/platcat --create-home \ + --shell /bin/bash platcat + +# Binaries straight from the build stage. +COPY --from=build-stage /out/catalog-emit /usr/local/bin/catalog-emit +COPY --from=build-stage /out/event-emit-publisher /usr/local/bin/event-emit-publisher + +# Make them owned by platcat so a non-root pod UID can run them. +RUN chown platcat:platcat /usr/local/bin/catalog-emit \ + /usr/local/bin/event-emit-publisher + +USER platcat:platcat +WORKDIR /home/platcat + +# Healthcheck surfaces what's running; useful for `kubectl describe pod` +# when watching the projection consumer come up. +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD /usr/local/bin/catalog-emit -h >/dev/null 2>&1 || exit 1 + +# Production entrypoint is catalog-emit (the consumer harness); the +# publisher binary is intentionally a sibling so it's invokeable for +# smoke tests via `kubectl exec -- /usr/local/bin/event-emit-publisher ...`. +# The k8s Deployment (52-catalog-emit.yaml) overrides args with the +# watch-mode flags + -out-dir pointing at the projection PVC. +ENTRYPOINT ["/usr/local/bin/catalog-emit"] +CMD ["-h"] diff --git a/catalog/builder.go b/catalog/builder.go new file mode 100644 index 0000000..74478f8 --- /dev/null +++ b/catalog/builder.go @@ -0,0 +1,386 @@ +// Builder applies a Schema to a generic source map[string]any and returns +// the platform-projected Projection. Pure function — no I/O, no mutation, +// safe to call concurrently. Coercions are best-effort: +// +// - missing source field ⇒ Schema default (FixedCurrency/DefaultTaxClass +// when present, otherwise empty/zero) +// - wrong-typed source value ⇒ the Builder either coerces (bools from +// truthy strings, ints from numeric strings, json.Number, etc.) or +// returns ErrProjectionBuild so the caller can decide whether to +// log+skip or fail. +// +// Builder never panics on bad input — a malformed source line is a log+skip, +// not a process crash. +// +// Three coercers cover realistic producer output shapes: +// +// - stock json.Unmarshal gives numeric fields as float64 unless +// `dec.UseNumber()` is on, in which case they come as json.Number. +// - json.Number preserves up to ~17 digits of precision; float64 +// silently loses precision past 2^53. A producer that wants exact +// prices sets UseNumber(); this Builder must accept the result. +// - some producers serialise via reflection and emit `int` / `int64` +// directly; this Builder must accept those too. +// +// All amounts leave the Builder as money.Cents (integer minor units). The +// only float-side conversion lives in platform/money (FromFloatMajor) and +// is the explicit lossy-edge; once in Cents, no float is ever summed, +// multiplied, or compared downstream. +// +// SCALE SEMANTICS used by coercePrice: +// +// - PriceScale == 1: the source value is ALREADY in minor units. +// * For string / json.Number: ParseInt directly (integer form only — +// the fractional-vs-low-scale guard above rejects "."-containing +// inputs). +// * For int / int64: identity (multiply by 1). +// * For float64 / float32: the float must be integral (else rejected); +// cast to int gives minor units directly. +// +// - PriceScale > 1 (e.g. 100): the source value is in MAJOR units. +// * For string / json.Number: ParseCents treats the decimal form as +// MAJOR and returns MINOR cents exactly — no further multiply. +// * For int / int64: integer multiply (lossless). +// * For float64 / float32: float multiply + round-half-away (lossy). +package catalog + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "git.k6n.net/mats/platform/money" +) + +// ErrProjectionBuild is returned when a source value cannot be coerced +// into the Projection shape. Callers typically log + skip so a single bad +// item doesn't poison a whole batch. +var ErrProjectionBuild = errors.New("catalog: projection build failed") + +// Apply is the pure helper signature: schema, source map → Projection, +// error. Use the package-level BuildFromMap helper for the most common +// shape (schema + map → Projection); use Apply when you want fine control. +func Apply(s Schema, source map[string]any) (Projection, error) { + if s.SKUField == "" || s.PriceField == "" { + return Projection{}, fmt.Errorf("%w: schema missing sku_field or price_field", ErrProjectionBuild) + } + if source == nil { + return Projection{}, fmt.Errorf("%w: nil source map", ErrProjectionBuild) + } + sku := stringOf(source, s.SKUField) + if sku == "" { + return Projection{}, fmt.Errorf("%w: missing SKU at %q", ErrProjectionBuild, s.SKUField) + } + + priceRaw, ok := source[s.PriceField] + if !ok { + return Projection{}, fmt.Errorf("%w: missing price at %q", ErrProjectionBuild, s.PriceField) + } + price, err := coercePrice(priceRaw, s.PriceScale) + if err != nil { + return Projection{}, fmt.Errorf("%w: price at %q: %w", ErrProjectionBuild, s.PriceField, err) + } + + cur := "" + if s.CurrencyField != "" { + cur = stringOf(source, s.CurrencyField) + } + if cur == "" { + cur = s.FixedCurrency + } + + tax := "" + if s.TaxClassField != "" { + tax = stringOf(source, s.TaxClassField) + } + if tax == "" { + tax = s.DefaultTaxClass + } + + id := sku + if s.IDPrefix != "" { + id = s.IDPrefix + sku + } + + upd, err := coerceUpdatedAt(s, source) + if err != nil { + return Projection{}, fmt.Errorf("%w: updated_at: %w", ErrProjectionBuild, err) + } + + truthy := s.TrueValues + if truthy == nil { + truthy = DefaultTrueValues() + } + + p := Projection{ + ID: id, + SKU: sku, + Title: stringOf(source, s.TitleField), + PriceIncVat: price, + Currency: cur, + TaxClass: tax, + InventoryTracked: boolOf(source, s.InventoryTrackedF, truthy), + DropShip: boolOf(source, s.DropShipF, truthy), + Category: stringOf(source, s.CategoryField), + Brand: stringOf(source, s.BrandField), + Image: stringOf(source, s.ImageField), + UpdatedAt: upd, + } + return p, nil +} + +// BuildFromMap is the convenience: schema + map → Projection with error. +// Use this from producer adapters where the source is a parsed map[string]any. +func BuildFromMap(s Schema, source map[string]any) (Projection, error) { + return Apply(s, source) +} + +// stringOf returns the value at key as a string. JSON unmarshalling gives +// strings as string; we accept the common numeric source shapes too so a +// map[string]any that came from a different upstream parser (e.g. typed +// structs serialised with one round of reflection) still works. +// +// json.Number is included because precision-preserving producers turn it on +// via json.Decoder.UseNumber(); without this case the Builder would reject +// every projection in the catalogue. +func stringOf(m map[string]any, key string) string { + if key == "" { + return "" + } + v, ok := m[key] + if !ok || v == nil { + return "" + } + switch x := v.(type) { + case string: + return x + case json.Number: + // Use Number preserves up to ~17 digits as a string-token. For SKUs + // digit-strings >2^53 (rare but possible), UseNumber is the only + // way to round-trip safely. String() gives back the raw digits. + return x.String() + case bool: + if x { + return "true" + } + return "false" + case float64: + if x == float64(int64(x)) { + return strconv.FormatInt(int64(x), 10) + } + return strconv.FormatFloat(x, 'f', -1, 64) + case int: + return strconv.Itoa(x) + case int64: + return strconv.FormatInt(x, 10) + } + return fmt.Sprintf("%v", v) +} + +// 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. +func boolOf(m map[string]any, key string, truthy []string) bool { + if key == "" { + return false + } + v, ok := m[key] + if !ok || v == nil { + return false + } + switch x := v.(type) { + case bool: + return x + case string: + low := strings.ToLower(strings.TrimSpace(x)) + for _, t := range truthy { + if low == t { + return true + } + } + return false + case float64: + return x != 0 + case int: + return x != 0 + case int64: + return x != 0 + case json.Number: + if f, err := x.Float64(); err == nil { + return f != 0 + } + return false + } + return false +} + +// coercePrice normalises a price source value to money.Cents (integer minor +// units). The schema's PriceScale determines whether the source is already +// in minor units (scale==1) or in MAJOR units that need interpretation +// (scale > 1, typically 100). See the package doc for the full table. +// +// The fractional-vs-low-scale guard REJECTS a fractional value when scale ≤ 1 +// at any of the float / string / json.Number arms — that's almost certainly a +// tenant who forgot PriceScale=100 and would have produced a silent 100×-off +// price. Loud-fail beats silent corruption. +// +// At scale > 1, decimals come through money.ParseCents (exact, integer-only) +// rather than ParseFloat (lossy past 2^53). Float arms remain the explicit +// lossy bits, intentionally isolated. +func coercePrice(v any, scale int) (money.Cents, error) { + if scale < 0 { + return 0, fmt.Errorf("negative price_scale: %d", scale) + } + effective := int64(scale) + if effective < 1 { + effective = 1 + } + // Fractional-vs-low-scale guard is inlined at each arm so the error + // message keeps the actual value (the float stringification is noisier + // than the JSON-token ".eE" check, so a single helper would lose + // fidelity). + switch x := v.(type) { + case float64: + if x != float64(int64(x)) && effective == 1 { + return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x) + } + return money.Cents(roundHalfAwayFromZero(x * float64(effective))), nil + case float32: + if x != float32(int64(x)) && effective == 1 { + return 0, fmt.Errorf("fractional price %v with PriceScale=1 — did you forget PriceScale=100?", x) + } + return money.Cents(roundHalfAwayFromZero(float64(x) * float64(effective))), nil + case int: + return money.Cents(int64(x) * effective), nil + case int64: + return money.Cents(x * effective), nil + case string: + s := strings.TrimSpace(x) + if s == "" { + return 0, fmt.Errorf("empty price string") + } + // Scale 1 with a fractional character set ⇒ foot-gun 4. + if effective == 1 && strings.ContainsAny(s, ".eE") { + return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s) + } + if effective == 1 { + // Scale 1 means source IS minor units. Remaining forms are + // integer decimal strings — ParseInt directly. ParseCents would + // misread "14995" as 14995.00 SEK = 1499500. + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse price %q at scale=1: %w", x, err) + } + return money.Cents(n), nil + } + // Scale > 1: source is MAJOR units. ParseCents interprets the + // decimal form as MAJOR and returns MINOR (cents) EXACTLY — no + // further multiply. + if c, err := money.ParseCents(s); err == nil { + return c, nil + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, fmt.Errorf("parse price %q: %w", x, err) + } + return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil + case json.Number: + s := x.String() + if effective == 1 && strings.ContainsAny(s, ".eE") { + return 0, fmt.Errorf("fractional price %q with PriceScale=1 — did you forget PriceScale=100?", s) + } + if effective == 1 { + // x is the original json.Number — Int64() works. + if i, err := x.Int64(); err == nil { + return money.Cents(i), nil + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse price json.Number %q at scale=1: %w", s, err) + } + return money.Cents(n), nil + } + // Scale > 1: ParseCents token-aware path. ParseCents interprets + // the token as MAJOR-units decimal and returns MINOR cents exactly. + if c, err := money.ParseCents(s); err == nil { + return c, nil + } + f, err := x.Float64() + if err != nil { + return 0, fmt.Errorf("parse price json.Number %q: %w", s, err) + } + return money.Cents(roundHalfAwayFromZero(f * float64(effective))), nil + } + return 0, fmt.Errorf("unsupported price type %T", v) +} + +// roundHalfAwayFromZero rounds half-away-from-zero instead of banker's +// rounding so e.g. 99.995 → 100 (with scale=1) not 99, and -99.995 → -100 +// not -99. We accept the very small skew in favour of deterministic +// conventional rounding for signed amounts (refunds, discounts). +func roundHalfAwayFromZero(f float64) int64 { + if f >= 0 { + return int64(f + 0.5) + } + return int64(f - 0.5) +} + +// coerceUpdatedAt returns Unix-ms for the Projection.UpdatedAt stamp based +// on Schema.UpdatedAtSource. +// +// IMPORTANT contract (foot-gun 3, resolved): +// +// - StampAtPublish is the RESERVED deliberate-fallback. Builder sets +// time.Now().UnixMilli(). Every rebuild re-stamps every projection, +// invalidating every consumer's whole cache even when nothing changed. +// Pick this only when the producer genuinely has no version stamp. +// +// - UseSourceField (DefaultSchema, BygglagretSchema) reads the value +// from source[Schema.UpdatedAtField]. If the field is MISSING or the +// value can't be parsed, Apply returns ErrProjectionBuild (wrapped) +// rather than stamp-now'ing. That keeps UpdatedAt a true content +// version across rebuilds — same source content ⇒ same UpdatedAt ⇒ +// consumer caches stay valid. +// +// Consumers compare UpdatedAt with "strictly greater wins" semantics; a +// silent stamp-now fallback would defeat this contract. +func coerceUpdatedAt(s Schema, source map[string]any) (int64, error) { + if s.UpdatedAtSource == StampAtPublish || s.UpdatedAtField == "" { + return time.Now().UnixMilli(), nil + } + v, ok := source[s.UpdatedAtField] + if !ok || v == nil { + return 0, fmt.Errorf("missing updated_at at %q (UseSourceField requires the source to supply it)", s.UpdatedAtField) + } + switch x := v.(type) { + case int64: + return x, nil + case int: + return int64(x), nil + case float64: + return int64(x), nil + case json.Number: + if i, err := x.Int64(); err == nil { + return i, nil + } + f, err := x.Float64() + if err != nil { + return 0, fmt.Errorf("updated_at json.Number %q: %w", x.String(), err) + } + return int64(f), nil + case string: + t := strings.TrimSpace(x) + if t == "" { + return 0, fmt.Errorf("empty updated_at string at %q (UseSourceField requires a value)", s.UpdatedAtField) + } + n, err := strconv.ParseInt(t, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse updated_at %q: %w", x, err) + } + return n, nil + } + return 0, fmt.Errorf("unsupported updated_at type %T at %q", v, s.UpdatedAtField) +} diff --git a/catalog/builder_test.go b/catalog/builder_test.go new file mode 100644 index 0000000..719f545 --- /dev/null +++ b/catalog/builder_test.go @@ -0,0 +1,162 @@ +package catalog + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" +) + +// json.Number from a json.Decoder running with UseNumber() is the +// canonical path for precision-preserving producer parsing. Without this +// case, BuildFromMap would log+skip every projection that came through +// UseNumber — silently losing items. +func TestBuilderJSONNumber(t *testing.T) { + // json.Number for SKU + src := map[string]any{ + "sku": json.Number("123456789012345"), // float64-unsafe + "title": "BigNumber SKU", + "price_inc_vat": json.Number("14995"), // already øre + "currency": "SEK", + "tax_class": "standard", + "updated_at": json.Number("1700000000000"), + } + p, err := Apply(DefaultSchema(), src) + if err != nil { + t.Fatalf("json.Number source rejected: %v", err) + } + if p.SKU != "123456789012345" { + t.Errorf("SKU: got %q want %q", p.SKU, "123456789012345") + } + if p.PriceIncVat != 14995 { + t.Errorf("PriceIncVat: got %d want %d", p.PriceIncVat, 14995) + } + if p.UpdatedAt != 1700000000000 { + t.Errorf("UpdatedAt: got %d want %d", p.UpdatedAt, int64(1700000000000)) + } +} + +// Updated=UseSourceField: missing source updated_at MUST be a loud error, +// not a stamp-now fallback. (Foot-gun 3 resolution: silence here invalidates +// every consumer's cache on rebuild.) +func TestBuilderRequiresUpdatedAt(t *testing.T) { + src := map[string]any{ + "sku": "X-1", + "price_inc_vat": int64(100), + // no "updated_at" key intentionally + } + _, err := Apply(DefaultSchema(), src) + if err == nil { + t.Fatal("UseSourceField + missing updated_at should error, not stamp-now") + } + if !errors.Is(err, ErrProjectionBuild) { + t.Errorf("got %v want ErrProjectionBuild", err) + } + if !strings.Contains(err.Error(), "updated_at") { + t.Errorf("error should mention updated_at: %v", err) + } +} + +// Same projection under StampAtPublish succeeds even without updated_at — +// that's the deliberate-fallback path. Used by sources with no other stamp. +// Bind the stamp to real wall-clock time (before ≤ actual ≤ after+slack) +// so the test distinguishes "stamp-now was called" from any other non-zero +// path. +func TestBuilderStampAtPublishFallback(t *testing.T) { + s := DefaultSchema() + s.UpdatedAtSource = StampAtPublish + s.UpdatedAtField = "" // ignored when source is StampAtPublish + before := time.Now().UnixMilli() + src := map[string]any{ + "sku": "X-2", + "price_inc_vat": int64(100), + } + p, err := Apply(s, src) + if err != nil { + t.Fatalf("StampAtPublish should accept missing updated_at: %v", err) + } + after := time.Now().UnixMilli() + 1000 // generous slack for slow CI + if p.UpdatedAt < before || p.UpdatedAt > after { + t.Errorf("UpdatedAt should be stamp-now Unix-ms in [%d,%d], got %d", before, after, p.UpdatedAt) + } +} + +// Fractional float price with scale=1 must be loud-fail — the float arm of +// coercePrice has the same foot-gun 4 risk as the string arm (silent 100x +// bias if a tenant forgets PriceScale=100 and ships a float literal). +func TestBuilderFractionalFloatLowScaleRejected(t *testing.T) { + src := map[string]any{ + "sku": "F-1", + "price_inc_vat": 99.95, // float major-units at scale=1 + "updated_at": int64(1700000000000), + } + _, err := Apply(DefaultSchema(), src) + if err == nil { + t.Fatal("fractional float price at scale=1 should error, not silently produce 100 øre") + } + if !errors.Is(err, ErrProjectionBuild) { + t.Errorf("got %v want ErrProjectionBuild", err) + } + if !strings.Contains(err.Error(), "PriceScale=100") { + t.Errorf("error message should mention PriceScale=100 hint: %v", err) + } +} + +// json.Number("149.95") at scale=1 must reach the fractional guard via +// the json.Number branch — the precision-safe producer route. +func TestBuilderFractionalJSONNumberLowScaleRejected(t *testing.T) { + src := map[string]any{ + "sku": "JN-1", + "price_inc_vat": json.Number("149.95"), + "updated_at": int64(1700000000000), + } + _, err := Apply(DefaultSchema(), src) + if err == nil { + t.Fatal("fractional json.Number at scale=1 should error, not silently produce 150 øre") + } + if !errors.Is(err, ErrProjectionBuild) { + t.Errorf("got %v want ErrProjectionBuild", err) + } + if !strings.Contains(err.Error(), "PriceScale=100") { + t.Errorf("error message should mention PriceScale=100 hint: %v", err) + } +} + +// Fractional string price with scale=1 must be loud-fail (a misconfigured +// tenant shipping major units without PriceScale=100 — silent 100x bias). +func TestBuilderFractionalStringPriceLowScaleRejected(t *testing.T) { + src := map[string]any{ + "sku": "X-1", + "price_inc_vat": "149.95", // major units FRACTION at scale=1 + "updated_at": int64(1700000000000), + } + _, err := Apply(DefaultSchema(), src) + if err == nil { + t.Fatal("fractional string at scale=1 should error, not silently produce 150 øre") + } + if !errors.Is(err, ErrProjectionBuild) { + t.Errorf("got %v want ErrProjectionBuild", err) + } + if !strings.Contains(err.Error(), "PriceScale=100") { + t.Errorf("error message should mention PriceScale=100 hint: %v", err) + } +} + +// Same string with scale=100 (correct major-unit config) succeeds. +func TestBuilderFractionalStringPriceCorrectScale(t *testing.T) { + s := DefaultSchema() + s.PriceScale = 100 + src := map[string]any{ + "sku": "X-2", + "price_inc_vat": "149.95", + "updated_at": int64(1700000000000), + } + p, err := Apply(s, src) + if err != nil { + t.Fatalf("scale=100 string price rejected: %v", err) + } + if p.PriceIncVat != 14995 { + t.Errorf("PriceIncVat: got %d want %d", p.PriceIncVat, 14995) + } +} diff --git a/catalog/doc.go b/catalog/doc.go new file mode 100644 index 0000000..98edee2 --- /dev/null +++ b/catalog/doc.go @@ -0,0 +1,41 @@ +// Package catalog defines the published, versioned catalog projection that +// commerce modules (cart, checkout, order) consume — and the configurable +// per-tenant Schema that maps a producer's private item JSON into that +// projection. +// +// Why configurable: different tenants ship with different catalog shapes +// (one has ArticleNumber, another has sku; one's price is inc-VAT minor units, +// another's is a float with a separate currency; some include a Brand field, +// others don't). A one-size-fits-all Projection field set would force every +// tenant to field the union of all constraints; the configurable Schema keeps +// each tenant's producer adapter small while still emitting the same +// downstream wire shape, so commerce consumers do not change. +// +// What's here: +// +// - Projection: the published, versioned entry (struct + Encode helpers). +// - Schema: declarative field-map for one tenant's source JSON. +// - DefaultSchema: the consensus shape that works out of the box when no +// tenant-specific override is supplied. +// - Registry: per-tenant lookup with DefaultSchema fallback. +// - Builder: applies a Schema to a generic source map; pure function. +// +// What is NOT here (kept out by design): +// +// - Numerics live in minor units (öre) and stay int64 — never float. The +// platform-wide rate-scale convention (basis-points-of-a-percent) is +// documented in platform/tax; TaxClass is a STRING identifier on the +// Projection side and is resolved to a numeric rate by platform/tax, never +// on the wire. +// - No transport, no AMQP consumer/producer, no event-subscriber wiring — +// callers publish / consume `Projection` JSON via the existing +// `platform/event` + `platform/rabbit` package plumbing. +// - No HTTP lookup. Finder remains the federation source; no commerce module +// issues a "lookup this SKU" RPC today and resolving it here would be +// premature. +// +// See docs/published-document-vocabulary-pass.md §11 for the wider vocabulary +// context (this package implements the cart side of that contract; the full +// vocabulary is the next pass and lives in `platform/published` once proven +// out). +package catalog diff --git a/catalog/projection.go b/catalog/projection.go new file mode 100644 index 0000000..d334ce8 --- /dev/null +++ b/catalog/projection.go @@ -0,0 +1,134 @@ +// Package catalog — see doc.go for the package summary. +// +// This file holds the published Projection struct: the wire shape that +// commerce modules receive. Every producer adapter (writeradmin, future CMS +// catalog adapter, ...) emits this exact shape; every consumer (cart, +// checkout, order, UCP add-to-cart) reads from this exact shape. Per-tenant +// field remapping happens in Schema + Builder (schema.go, builder.go). +// +// Mapping source JSON → Projection is per-tenant and lives in Schema. +// The consensus mapping for DefaultSchema() is: +// +// id ← IDPrefix + SKU (e.g. "sku:") +// sku ← source[SKUField] +// title ← source[TitleField] (omitempty, optional) +// price_inc_vat ← coercive parse of source[PriceField] × PriceScale +// currency ← source[CurrencyField], or FixedCurrency +// tax_class ← source[TaxClassField] (string identifier) +// inventory_tracked ← bool coercion of source[InventoryTrackedField] +// drop_ship ← bool coercion of source[DropShipField] +// category ← source[CategoryField] (omitempty) +// brand ← source[BrandField] (omitempty) +// image ← source[ImageField] (omitempty) +// updated_at ← Unix-ms from source[UpdatedAtField] OR stamped at +// publish time when UpdatedAtSource == StampAtPublish +// +// SCALE (rate-bearing fields): any field that carries a tax rate uses the +// platform-wide basis-points-of-a-percent convention (rate × 100; 25% = 2500, +// 12.5% = 1250, 8.1% = 810). Never float. Producer-side fields stay in +// minor currency units (öre / cents) for amounts — already int64. TaxClass +// on the wire is a STRING identifier; numeric rate resolution is a platform/tax +// concern (out of package scope by design). +package catalog + +import ( + "encoding/json" + + "git.k6n.net/mats/platform/money" +) + +// Projection is the published, versioned catalog entry that commerce modules +// consume. Finder owns updating it; cart / checkout / order read it. +// +// Versioning: UpdatedAt is a monotonic Unix-millisecond timestamp stamped by +// the producer at the time the AMQP event is emitted (or read from a +// designated source field, see Schema.UpdatedAtSource). Consumers, when +// revalidating, treat a strictly greater UpdatedAt as the authoritative +// replacement and refresh the local copy. +// +// Field rationale: +// +// ID — opaque published 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 +// checkout/order payload — see platform-uniforming-plan.md. +// Currency — optional but recommended; absent ⇒ caller supplies. +// TaxClass — class identifier (string). Numeric rate resolution is +// a platform/tax concern (see platform/tax.Provider). This +// struct never carries a numeric rate; that keeps the +// rate-scale convention in one place and keeps the wire +// free of consumer-policy values. +// InventoryTracked / DropShip — fulfillment-routing flags. The C1 contract +// asks for these so the cart can pre-mark drop-ship +// items before checkout and inventory can opt-out of +// tracking for non-stock items. +// Category / Brand / Image — display projection only. +// UpdatedAt — version stamp for snapshot + revalidate. +type Projection struct { + 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). + // Empty string means "unclassified"; the consumer's tax.Provider is what + // resolves the identifier to a numeric rate. + TaxClass string `json:"tax_class"` + InventoryTracked bool `json:"inventory_tracked"` + DropShip bool `json:"drop_ship"` + Category string `json:"category,omitempty"` + Brand string `json:"brand,omitempty"` + Image string `json:"image,omitempty"` + UpdatedAt int64 `json:"updated_at"` +} + +// PayloadKey is the envelope key producers use when publishing a slice of +// projections over the bus as an `event.Event.Payload` body. Consumers parse +// this exact key — never a per-producer ad-hoc name — so the wire shape is +// stable. +const PayloadKey = "catalog_projection" + +// Encode serialises a Projection to JSON suitable for the event.Payload. +// Kept as a package-level helper so producers and tests share the exact wire +// shape; any future wire renaming lands here, not in every caller. +func (p Projection) Encode() ([]byte, error) { + return json.Marshal(p) +} + +// EncodeBatch serialises a slice to a JSON array. +func EncodeBatch(batch []Projection) ([]byte, error) { + return json.Marshal(batch) +} + +// ProjectionUpdate is one entry in a CatalogProjectionPublished batch. An +// upsert carries a fully-built Projection. A delete sets Deleted=true; only SKU +// (and ID) are then meaningful and the consumer drops that SKU from its +// snapshot / cache. Modelling delete as a flag (not a nil payload) keeps a +// change-set's upserts and deletes together in one ordered event. +type ProjectionUpdate struct { + Projection + Deleted bool `json:"deleted,omitempty"` +} + +// EncodeUpdates serialises a batch for the event.Event.Payload body. +func EncodeUpdates(batch []ProjectionUpdate) ([]byte, error) { + return json.Marshal(batch) +} + +// DecodeUpdates parses a CatalogProjectionPublished payload — the consumer side +// of EncodeUpdates. +func DecodeUpdates(raw []byte) ([]ProjectionUpdate, error) { + var out []ProjectionUpdate + err := json.Unmarshal(raw, &out) + return out, err +} + +// Decode parses a Projection from JSON. Symmetric with Encode so consumer +// tests can round-trip a fixture without depending on the Builder. +func Decode(raw []byte) (Projection, error) { + var p Projection + err := json.Unmarshal(raw, &p) + return p, err +} diff --git a/catalog/registry.go b/catalog/registry.go new file mode 100644 index 0000000..96f7ef4 --- /dev/null +++ b/catalog/registry.go @@ -0,0 +1,144 @@ +// Registry is the per-tenant Schema lookup. A producer processes tenant X: +// it builds an event.Event with Source="catalog:" and asks the +// Registry for X's Schema; the Builder applies that Schema to the source +// JSON. Unknown tenants get DefaultSchema — that makes the platform +// "just work" for new tenants while the operator rolls out the proper +// override file. +package catalog + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "sync" +) + +// Registry is concurrency-safe: Register / SchemaFor can be called from any +// goroutine. SchemaFor is the hot path; it takes a read lock. +type Registry struct { + mu sync.RWMutex + schemas map[string]Schema + def Schema // the "default" / DefaultSchema() fallback +} + +// NewRegistry returns an empty Registry whose DefaultSchema fallback is +// DefaultSchema(). Use Register to add tenant-specific overrides. +func NewRegistry() *Registry { + return &Registry{ + schemas: map[string]Schema{}, + def: DefaultSchema(), + } +} + +// WithDefault swaps the DefaultSchema fallback (used when SchemaFor is +// called with an unknown tenant name, or with the literal "default"). +// Returns the receiver so it composes with NewRegistry(). +func (r *Registry) WithDefault(s Schema) *Registry { + if s.Tenant == "" { + s.Tenant = "default" + } + r.mu.Lock() + defer r.mu.Unlock() + r.def = s + return r +} + +// Register stores s under s.Tenant. A Schema with empty Tenant is stored +// under "default" and ALSO replaces the DefaultSchema fallback. +func (r *Registry) Register(s Schema) error { + if s.SKUField == "" || s.PriceField == "" { + return fmt.Errorf("catalog: schema for %q must declare sku_field and price_field", s.Tenant) + } + if s.PriceScale < 0 { + return fmt.Errorf("catalog: schema %q has negative price_scale (%d)", s.Tenant, s.PriceScale) + } + // Foot-gun 3, registry-side guard: UseSourceField without a field is + // the silent stamp-now path. Refuse at load time so a JSON typo + // surfaces immediately rather than invalidating caches per-rebuild. + if s.UpdatedAtSource == UseSourceField && s.UpdatedAtField == "" { + return fmt.Errorf("catalog: schema %q has updated_at_source=UseSourceField but no updated_at_field (would silently stamp-now)", s.Tenant) + } + if s.TrueValues == nil { + s.TrueValues = DefaultTrueValues() + } + key := s.Tenant + if key == "" { + key = "default" + s.Tenant = key + r.mu.Lock() + defer r.mu.Unlock() + r.def = s + r.schemas[key] = s + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + r.schemas[key] = s + return nil +} + +// SchemaFor returns the Schema registered for tenant, or the DefaultSchema +// fallback when tenant is "" or not registered. +func (r *Registry) SchemaFor(tenant string) Schema { + r.mu.RLock() + defer r.mu.RUnlock() + if tenant == "" { + return r.def + } + if s, ok := r.schemas[tenant]; ok { + return s + } + return r.def +} + +// Tenants returns the sorted list of registered tenant names (excluding the +// default fallback). Useful for ops dashboards and "what schemas are loaded". +func (r *Registry) Tenants() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.schemas)) + for k := range r.schemas { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// LoadFile reads a JSON file of { "tenant": Schema, ... } and registers each +// entry into r. The JSON shape mirrors the Schema struct exactly (json tags +// in schema.go). Missing file is OK (file is optional); unreadable file +// returns the error so a misconfigured deployment fails loudly on startup. +func (r *Registry) LoadFile(path string) error { + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("catalog: read schema file %q: %w", path, err) + } + var raw map[string]Schema + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("catalog: parse schema file %q: %w", path, err) + } + // Walk in sorted order so operator-visible loading is deterministic + // (helps when something fails partway through). + keys := make([]string, 0, len(raw)) + for k := range raw { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + s := raw[k] + if s.Tenant == "" { + s.Tenant = k + } + if err := r.Register(s); err != nil { + return fmt.Errorf("catalog: schema %q in %q: %w", k, path, err) + } + } + return nil +} diff --git a/catalog/registry_test.go b/catalog/registry_test.go new file mode 100644 index 0000000..e67fac8 --- /dev/null +++ b/catalog/registry_test.go @@ -0,0 +1,107 @@ +package catalog + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +// TestRegistryLookupAndDefault — registered tenant wins, unknown falls back. +func TestRegistryLookupAndDefault(t *testing.T) { + r := NewRegistry() + if got := r.SchemaFor("missing"); !reflect.DeepEqual(got, DefaultSchema()) { + t.Errorf("unknown tenant did not fall back to DefaultSchema") + } + bygg := BygglagretSchema() + if err := r.Register(bygg); err != nil { + t.Fatalf("Register: %v", err) + } + got := r.SchemaFor("bygglagret") + if got.SKUField != "ArticleNumber" { + t.Errorf("Register did not store under Tenant: %+v", got) + } + if got.SKUField == DefaultSchema().SKUField { + t.Errorf("Expected bygglagret to differ from default") + } +} + +// TestRegisterRejectsInvalidSchema — guard against typos in operator JSON. +func TestRegisterRejectsInvalidSchema(t *testing.T) { + r := NewRegistry() + err := r.Register(Schema{Tenant: "broken", PriceField: "x"}) // missing SKUField + if err == nil { + t.Error("expected error for schema without SKUField") + } + err = r.Register(Schema{Tenant: "negscale", SKUField: "s", PriceField: "p", PriceScale: -1}) + if err == nil { + t.Error("expected error for negative PriceScale") + } +} + +// TestTenantsSorted — dashboard-friendly ordering. +func TestTenantsSorted(t *testing.T) { + r := NewRegistry() + for _, k := range []string{"zeta", "alpha", "mu"} { + s := Schema{Tenant: k, SKUField: "s", PriceField: "p"} + if err := r.Register(s); err != nil { + t.Fatalf("Register %s: %v", k, err) + } + } + got := r.Tenants() + want := []string{"alpha", "mu", "zeta"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Tenants() got %v want %v", got, want) + } +} + +// TestLoadFileMissingIsOK — file is optional. +func TestLoadFileMissingIsOK(t *testing.T) { + r := NewRegistry() + if err := r.LoadFile(filepath.Join(t.TempDir(), "nope.json")); err != nil { + t.Errorf("missing file should be a no-op, got %v", err) + } +} + +// TestLoadFileHappy — load tenant schemas from disk and look one up. +func TestLoadFileHappy(t *testing.T) { + // JSON shape mirrors schema.go json tags; tenant keys ("bygglagret") are + // the registry names. + jsonBody := `{ + "bygglagret": { + "sku_field": "ArticleNumber", + "price_field": "PriceInclVAT", + "price_scale": 100, + "id_prefix": "sku:", + "updated_at_source": 1, + "updated_at_field": "LastModifiedMs", + "fixed_currency": "SEK" + }, + "north": { + "sku_field": "sku", + "price_field": "price_minor", + "price_scale": 1 + } + }` + dir := t.TempDir() + path := filepath.Join(dir, "schemas.json") + writeFile(t, path, jsonBody) + r := NewRegistry() + if err := r.LoadFile(path); err != nil { + t.Fatalf("LoadFile: %v", err) + } + if got := r.SchemaFor("bygglagret").PriceField; got != "PriceInclVAT" { + t.Errorf("bygglagret not loaded: %q", got) + } + if got := r.SchemaFor("north").PriceScale; got != 1 { + t.Errorf("north not loaded: %d", got) + } +} + +// writeFile is a local helper; writes the JSON tenant-schema fixture to path. +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/catalog/schema.go b/catalog/schema.go new file mode 100644 index 0000000..c7631dd --- /dev/null +++ b/catalog/schema.go @@ -0,0 +1,171 @@ +// 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"` + BrandField string `json:"brand_field,omitempty"` + ImageField string `json:"image_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", + BrandField: "brand", + ImageField: "image", + UpdatedAtField: "updated_at", + + 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. +func BygglagretSchema() Schema { + return Schema{ + Tenant: "bygglagret", + SKUField: "ArticleNumber", + TitleField: "Name", + PriceField: "PriceInclVAT", + CurrencyField: "", // fixed below + TaxClassField: "VATClass", + InventoryTrackedF: "StockTracked", + DropShipF: "DropshipFlag", + CategoryField: "CategoryPath", + BrandField: "Brand", + ImageField: "PrimaryImage", + UpdatedAtField: "LastModifiedMs", + + 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"} +} diff --git a/catalog/schema_test.go b/catalog/schema_test.go new file mode 100644 index 0000000..00afcf5 --- /dev/null +++ b/catalog/schema_test.go @@ -0,0 +1,96 @@ +package catalog + +import ( + "encoding/json" + "testing" +) + +// TestSchemaRoundTrip verifies a Schema can be JSON-encoded and decoded +// without losing operator-tunable fields. Round-trip is the contract +// LoadFile relies on. +func TestSchemaRoundTrip(t *testing.T) { + original := BygglagretSchema() + original.TrueValues = []string{"true", "ja"} // tenant-specific + + raw, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded Schema + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if decoded.SKUField != original.SKUField { + t.Errorf("SKUField: got %q want %q", decoded.SKUField, original.SKUField) + } + if decoded.PriceScale != original.PriceScale { + t.Errorf("PriceScale: got %d want %d", decoded.PriceScale, original.PriceScale) + } + if decoded.UpdatedAtSource != original.UpdatedAtSource { + t.Errorf("UpdatedAtSource: got %v want %v", decoded.UpdatedAtSource, original.UpdatedAtSource) + } + if decoded.UpdatedAtField != original.UpdatedAtField { + t.Errorf("UpdatedAtField: got %q want %q", decoded.UpdatedAtField, original.UpdatedAtField) + } + if len(decoded.TrueValues) != 2 || decoded.TrueValues[1] != "ja" { + t.Errorf("TrueValues lost on round-trip: %v", decoded.TrueValues) + } +} + +// TestDefaultSchemaConsensusShape pins the field-name contract for the +// zero-config tenant (the operator-facing shape). +func TestDefaultSchemaConsensusShape(t *testing.T) { + s := DefaultSchema() + if s.Tenant != "default" { + t.Errorf("DefaultSchema().Tenant = %q, want %q", s.Tenant, "default") + } + if s.SKUField != "sku" || s.PriceField != "price_inc_vat" { + t.Errorf("consensus shape rotated: SKUField=%q PriceField=%q", s.SKUField, s.PriceField) + } + if s.PriceScale != 1 || s.IDPrefix != "sku:" { + t.Errorf("consensus defaults wrong: PriceScale=%d IDPrefix=%q", s.PriceScale, s.IDPrefix) + } + if s.UpdatedAtSource != UseSourceField { + t.Errorf("consensus updated_at_source = %v, want UseSourceField "+ + "(foot-gun 3: stamp-now'ing on rebuild invalidates every consumer cache)", s.UpdatedAtSource) + } + if len(s.TrueValues) == 0 { + t.Errorf("Truth set should not be empty in consensus") + } +} + +// TestTrueValueCaseInsensitive pins the lower-cased match used by boolOf. +func TestTrueValueCaseInsensitive(t *testing.T) { + truthy := DefaultTrueValues() + for _, s := range []string{"TRUE", "Yes", "ON", "1"} { + // Re-implement the matching here to avoid coupling to builder.go's + // internals, but flag if it ever drifts. + low := "" + for _, c := range s { + if c >= 'A' && c <= 'Z' { + low += string(c + 32) + } else { + low += string(c) + } + } + found := false + for _, t := range truthy { + if low == t { + found = true + break + } + } + if !found { + t.Errorf("truthy set is missing lower-cased %q", s) + } + } +} + +// TestSchemaZeroValueInvalid documents that the zero value of Schema is +// explicitly NOT a usable schema — Apply validates and refuses it. +func TestSchemaZeroValueInvalid(t *testing.T) { + _, err := Apply(Schema{}, map[string]any{"sku": "ABC", "price_inc_vat": 1}) + if err == nil { + t.Errorf("zero Schema should be rejected by Apply") + } +} diff --git a/catalog/snapshot.go b/catalog/snapshot.go new file mode 100644 index 0000000..6df72b6 --- /dev/null +++ b/catalog/snapshot.go @@ -0,0 +1,423 @@ +// Snapshot reader/writer for the published catalog Projection. +// +// Why a snapshot file at all? The bus is the live channel, but it doesn't +// carry the historical population on cold start. A snapshot file gives +// consumers (cart / checkout / order / UCP add-to-cart) a fast mmap-loaded +// cold-start substrate, so the first add-line request doesn't have to wait for +// the bus to replay. The bus then keeps it fresh. +// +// Format v2 (little-endian, fixed-width, mmap-friendly): +// +// header [72 bytes] magic(8) version(4) reserved(4) count(8) +// blobStart(8) recordStart(8) tenant(32, zero-padded) +// blobs variable count sequential JSON-encoded Projection bodies, no padding +// records count*44 each: sku(32, zero-padded) blobOffset(8) blobLength(4); +// sorted by SKU via the single compareSKU total order +// +// Tenant field at offsets 40–71 (zero-padded) names the namespace this +// snapshot covers. Independent SKU schemas can produce Projections into +// different tenant files without conflict. Hot-reload across tenants stays +// safe under SnapshotHolder (snapshot_manager.go). +// +// Sorting + binary search gives O(log N) per-SKU lookup with no on-disk hash +// table and no collisions. SKUs longer than 32 bytes are rejected at write +// time (Cart SKUs in this codebase are well under that). +// +// Memory model: the Reader holds one []byte for the entire file (mmap on +// Unix, ReadFile elsewhere). Per-SKU Get reads at most log2(N) record slots +// (each 44 B) and one JSON blob (~200 B). For 220 k projections the +// resident set is "pages touched this minute", not the whole file. +// +// SAFETY: SnapshotReader alone is NOT safe across hot-reload. During reload, +// in-flight Get() calls on the OLD reader continue to work until disposal +// of the old reader is finalised; calling SnapshotReader.Close() concurrently +// with Get() on the same reader risks unmapping pages-under-read (SIGBUS). +// Use SnapshotHolder (snapshot_manager.go) for any reload-bearing setup — +// it gives you an atomic.Pointer swap with a drain-grace (default 30s) so +// concurrent Gets on the OLD reader finish naturally before munmap fires. +package catalog + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "sync" +) + +const ( + snapshotMagic uint64 = 0x3230564a53435050 // "PCSJ002" v2 (was 0x3130564a53435050 = v1) + snapshotVersion uint32 = 2 + maxSKULen = 32 + maxTenantLen = 32 + headerSize = 8 + 4 + 4 + 8 + 8 + 8 + 32 // 72 bytes + recordSize = 32 + 8 + 4 // 44 bytes per record +) + +var ( + ErrSnapshotMagic = errors.New("catalog: not a snapshot file (bad magic)") + ErrSnapshotVersion = errors.New("catalog: unsupported snapshot version") + ErrSnapshotTruncated = errors.New("catalog: snapshot truncated") + ErrSnapshotBadBlob = errors.New("catalog: snapshot record points past file") + ErrSnapshotOverflow = errors.New("catalog: snapshot record-section size overflows uint64") + ErrSKUTooLong = fmt.Errorf("catalog: SKU longer than %d bytes", maxSKULen) + ErrTenantTooLong = fmt.Errorf("catalog: tenant name longer than %d bytes", maxTenantLen) +) + +// SnapshotWriter is single-shot: Append() all projections, then Write() once +// (atomic rename-into-place so readers never see a partial file). +type SnapshotWriter struct { + Tenant string // optional namespace tag (writes into header) + blobs []byte + records []snapshotRecord + dirty bool +} + +// snapshotRecord is the on-disk layout: SKU zero-padded to 32 bytes, then +// an 8-byte offset (from file start) plus a 4-byte length for the JSON blob. +type snapshotRecord struct { + SKU [32]byte + BlobOffset uint64 + BlobLength uint32 +} + +// NewSnapshotWriter returns an empty writer. Caller Appends every projection +// and calls Write once. +func NewSnapshotWriter() *SnapshotWriter { + return &SnapshotWriter{} +} + +// WithTenant tags this writer with a tenant namespace that lands in the +// snapshot header. Optional — empty tenant gets zero-padded; readers see an +// empty string when the writer was untagged. +func (w *SnapshotWriter) WithTenant(tenant string) *SnapshotWriter { + w.Tenant = tenant + return w +} + +// Append encodes and adds a projection. Returns ErrSKUTooLong if SKU exceeds +// the on-disk width; returns marshal errors as wrapped fmt.Errorf. +func (w *SnapshotWriter) Append(p Projection) error { + if len(p.SKU) > maxSKULen { + return ErrSKUTooLong + } + jb, err := json.Marshal(p) + if err != nil { + return fmt.Errorf("catalog: marshal projection %q: %w", p.SKU, err) + } + + var rec snapshotRecord + copy(rec.SKU[:], p.SKU) + rec.BlobOffset = uint64(headerSize + len(w.blobs)) + rec.BlobLength = uint32(len(jb)) + + w.blobs = append(w.blobs, jb...) + w.records = append(w.records, rec) + w.dirty = true + return nil +} + +// Len returns how many projections have been Append'd. +func (w *SnapshotWriter) Len() int { return len(w.records) } + +// Write serialises to a temp file in dir, fsync's, then renames into the +// final path. Records are sorted by SKU asc before serialisation so the +// reader's binary search works. +func (w *SnapshotWriter) Write(dir, name string) (finalPath string, err error) { + if w.dirty { + sort.Slice(w.records, func(i, j int) bool { + return compareSKU(w.records[i].SKU[:], w.records[j].SKU[:]) < 0 + }) + w.dirty = false + } + if len(w.Tenant) > maxTenantLen { + err = ErrTenantTooLong + return "", err + } + + if err = os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + finalPath = filepath.Join(dir, name) + tmp := finalPath + ".tmp" + + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return "", err + } + defer func() { + if err != nil { + _ = os.Remove(tmp) + _ = f.Close() + } + }() + + // Header: fixed 72 bytes (8 + 4 + 4 + 8 + 8 + 8 + 32). + blobStart := uint64(headerSize) + recordStart := blobStart + uint64(len(w.blobs)) + var hdr [headerSize]byte + binary.LittleEndian.PutUint64(hdr[0:8], snapshotMagic) + binary.LittleEndian.PutUint32(hdr[8:12], snapshotVersion) + binary.LittleEndian.PutUint32(hdr[12:16], 0) + binary.LittleEndian.PutUint64(hdr[16:24], uint64(len(w.records))) + binary.LittleEndian.PutUint64(hdr[24:32], blobStart) + binary.LittleEndian.PutUint64(hdr[32:40], recordStart) + copy(hdr[40:40+len(w.Tenant)], w.Tenant) // rest is zero-pad + + if _, werr := f.Write(hdr[:]); werr != nil { + err = werr + return "", err + } + if _, werr := f.Write(w.blobs); werr != nil { + err = werr + return "", err + } + recBytes := make([]byte, recordSize*len(w.records)) + for i, r := range w.records { + off := i * recordSize + copy(recBytes[off:off+32], r.SKU[:]) + binary.LittleEndian.PutUint64(recBytes[off+32:off+40], r.BlobOffset) + binary.LittleEndian.PutUint32(recBytes[off+40:off+44], r.BlobLength) + } + if _, werr := f.Write(recBytes); werr != nil { + err = werr + return "", err + } + + if werr := f.Sync(); werr != nil { + err = werr + return "", err + } + if werr := f.Close(); werr != nil { + err = werr + return "", err + } + if werr := os.Rename(tmp, finalPath); werr != nil { + err = werr + return "", err + } + return finalPath, nil +} + +// compareSKU defines the SINGLE TOTAL ORDER for SKUs across both Writer +// (uses it for sort.Slice) and Reader (uses it for binary search). One +// definition; invariant test verifies the on-disk result is sorted under +// it. Source SKUs SHOULD NOT contain embedded null bytes — the padded +// comparator treats them as terminators, which silently truncates an SKU. +// Realistically no SKU has NUL. +func compareSKU(padded, other []byte) int { + // padded is a fixed-size zero-padded slot; trim trailing zeros. + end := len(padded) + for end > 0 && padded[end-1] == 0 { + end-- + } + a := padded[:end] + b := other + + n := len(a) + if len(b) < n { + n = len(b) + } + for i := 0; i < n; i++ { + if a[i] != b[i] { + if a[i] < b[i] { + return -1 + } + return 1 + } + } + switch { + case len(a) < len(b): + return -1 + case len(a) > len(b): + return 1 + default: + return 0 + } +} + +// compareRecordLookup is compareSKU specialised for Reader lookups so the +// argument order matches the lex order: "padded" (fixed-slot) and "want" +// (query). +// +// compareSKU is symmetric so this is just an alias used by Reader code +// paths to make intent obvious. Any change to the total order here MUST be +// reflected in Writer.sort.Slice and verified by the invariant test. +func compareRecordLookup(padded, want []byte) int { + return compareSKU(padded, want) +} + +// SnapshotReader is read-only and concurrency-safe — parallel Get calls are +// OK because no fields are mutated after construction. Held open via an +// embedded file handle so the underlying mmap stays pinned. Close uses +// sync.Once so concurrent shutdown paths can't double-munmap. +// +// IMPORTANT — see package doc: SnapshotReader is NOT safe racing Close +// concurrently with Get. Use SnapshotHolder for any reload-bearing workflow. +type SnapshotReader struct { + data []byte + count uint64 + blobStart uint64 + recordStart uint64 + Tenant string // populated from header at Open time; "" if not set + + closeOnce sync.Once + closer func() error // munmap + close on Unix; no-op on ReadFile fallback +} + +// ReadSnapshotFile opens + parses a snapshot file. On Unix uses mmap; on +// other platforms reads into memory (functionally identical, higher RSS). +func ReadSnapshotFile(path string) (*SnapshotReader, error) { + data, closer, err := loadSnapshotBytes(path) + if err != nil { + return nil, err + } + r, err := newSnapshotReader(data) + if err != nil { + if closer != nil { + _ = closer() + } + return nil, err + } + r.closer = closer + return r, nil +} + +// NewSnapshotReader wraps an already-loaded []byte (use this in tests; for +// production paths go through ReadSnapshotFile). +func NewSnapshotReader(data []byte) (*SnapshotReader, error) { + return newSnapshotReader(data) +} + +func newSnapshotReader(data []byte) (*SnapshotReader, error) { + if len(data) < headerSize { + return nil, ErrSnapshotTruncated + } + magic := binary.LittleEndian.Uint64(data[0:8]) + if magic != snapshotMagic { + return nil, ErrSnapshotMagic + } + version := binary.LittleEndian.Uint32(data[8:12]) + if version != snapshotVersion { + return nil, ErrSnapshotVersion + } + count := binary.LittleEndian.Uint64(data[16:24]) + blobStart := binary.LittleEndian.Uint64(data[24:32]) + recordStart := binary.LittleEndian.Uint64(data[32:40]) + if blobStart != uint64(headerSize) { + return nil, fmt.Errorf("catalog: blobStart=%d expected %d", blobStart, headerSize) + } + + // Multiplication overflow guard: for a malformed/tampered count, the + // naive int(count)*recordSize in Go silently wraps on overflow, + // making expectedSize negative and bypassing the truncation guard. + // Compute as uint64 with an explicit overflow check. + recordBytes := uint64(recordSize) * count + if count > 0 && recordBytes/uint64(count) != uint64(recordSize) { + return nil, fmt.Errorf("%w: count*recordSize overflows (count=%d)", ErrSnapshotOverflow, count) + } + if recordStart > math.MaxUint64-recordBytes { + return nil, fmt.Errorf("%w: recordStart+records exceeds uint64 (recordStart=%d count=%d)", + ErrSnapshotOverflow, recordStart, count) + } + expectedSize := recordStart + recordBytes + if uint64(len(data)) < expectedSize { + return nil, ErrSnapshotTruncated + } + + // Per-record blob-offset bounds check: defends against malformed / + // tampered files where the record section is intact but the blob + // pointer lies past EOF. A linear pass; cheap (44 B × count at open). + // + // Avoid the naive blobOff+blobLen > dataLen check — blobOff can be + // MaxUint64 in tampered input and uint64 addition wraps, falsely + // passing the test. Split the check into overflow-safe parts. + dataLen := uint64(len(data)) + for i := uint64(0); i < count; i++ { + recOff := recordStart + i*uint64(recordSize) + blobLen := binary.LittleEndian.Uint32(data[recOff+40 : recOff+44]) + blobOff := binary.LittleEndian.Uint64(data[recOff+32 : recOff+40]) + if blobOff > dataLen || uint64(blobLen) > dataLen-blobOff { + return nil, fmt.Errorf("%w: record %d blobOff=%d len=%d exceeds file size %d", + ErrSnapshotBadBlob, i, blobOff, blobLen, dataLen) + } + } + + // Tenant: read zero-padded bytes at offset 40, trim trailing zeros. + tenantBytes := data[40 : 40+maxTenantLen] + tenantEnd := len(tenantBytes) + for tenantEnd > 0 && tenantBytes[tenantEnd-1] == 0 { + tenantEnd-- + } + tenant := string(tenantBytes[:tenantEnd]) + + return &SnapshotReader{ + data: data, + count: count, + blobStart: blobStart, + recordStart: recordStart, + Tenant: tenant, + }, nil +} + +// Len returns the number of Projections in this snapshot. +func (r *SnapshotReader) Len() int { return int(r.count) } + +// Close releases the file handle and mmap region (if any). Idempotent — safe +// to call from multiple goroutines (uses sync.Once under the hood). +// +// SAFETY: this is NOT safe against concurrent Get() — see package doc. Use +// SnapshotHolder for any reload-bearing workflow. +func (r *SnapshotReader) Close() error { + var err error + r.closeOnce.Do(func() { + if r.closer != nil { + err = r.closer() + } + }) + return err +} + +// Get returns the projection for sku, or (zero, false) if not found. +// O(log N) — binary search on the sorted record section; on hit, reads the +// JSON blob and decodes it (one allocation). +func (r *SnapshotReader) Get(sku string) (Projection, bool) { + if r.count == 0 || sku == "" { + return Projection{}, false + } + skuBytes := []byte(sku) + if len(skuBytes) > maxSKULen { + return Projection{}, false + } + + lo, hi := uint64(0), r.count + for lo < hi { + mid := (lo + hi) / 2 + recOff := int(r.recordStart) + int(mid)*recordSize + recSKU := r.data[recOff : recOff+32] + cmp := compareRecordLookup(recSKU, skuBytes) + switch { + case cmp < 0: + lo = mid + 1 + case cmp > 0: + hi = mid + default: + blobLen := binary.LittleEndian.Uint32(r.data[recOff+40 : recOff+44]) + blobOff := binary.LittleEndian.Uint64(r.data[recOff+32 : recOff+40]) + // Per-record bounds check at Open guarantees this slice is in-range + rdata := r.data[blobOff : blobOff+uint64(blobLen)] + var p Projection + if err := json.Unmarshal(rdata, &p); err != nil { + return Projection{}, false + } + return p, true + } + } + return Projection{}, false +} + + diff --git a/catalog/snapshot_helpers_test.go b/catalog/snapshot_helpers_test.go new file mode 100644 index 0000000..ac7df68 --- /dev/null +++ b/catalog/snapshot_helpers_test.go @@ -0,0 +1,22 @@ +// Local helpers for snapshot_test.go — production code does not see them. +package catalog + +import ( + "os" +) + +func os2WriteFile(path string, data []byte, perm os.FileMode) error { + return os.WriteFile(path, data, perm) +} + +func os2Stat(path string) (os.FileInfo, error) { + return os.Stat(path) +} + +func repeat32(s string, n int) string { + out := make([]byte, 0, n*len(s)) + for i := 0; i < n; i++ { + out = append(out, s...) + } + return string(out) +} diff --git a/catalog/snapshot_manager.go b/catalog/snapshot_manager.go new file mode 100644 index 0000000..7872516 --- /dev/null +++ b/catalog/snapshot_manager.go @@ -0,0 +1,201 @@ +// SnapshotHolder provides atomic hot-reload for SnapshotReader with drain +// grace — solves the classic "Get concurrent with Close/munmap" race that +// would otherwise produce uncatchable SIGBUS / SIGSEGV under reload. +// +// Pattern source: slask-finder's `RawItemsStore` and similar mmap+reload +// stores, which delay unmap behind a configurable drain-grace timer so any +// in-flight Get on the previously-current reader completes before the +// underlying pages become unmapped. See docs/inventory-shape.md & the +// finder WARP doc on mmap reload for the prior art. +// +// Use: +// +// holder := catalog.NewSnapshotHolder(30 * time.Second) // drain grace +// if err := holder.Open("/var/lib/catalog/bygglagret.bin"); err != nil { ... } +// // later, when a fresh snapshot lands: +// if err := holder.Reload("/var/lib/catalog/bygglagret.bin"); err != nil { ... } +// // pulls the projection; always hits the current reader: +// p, ok := holder.Get(sku) +// +// holder.Get / Len are safe for parallel use. Open and Reload are also +// safe for parallel use. Close stops the reaper goroutine and shuts the +// current reader + drains down. +package catalog + +import ( + "sync" + "sync/atomic" + "time" +) + +// DefaultDrainGrace is the default drain period for SnapshotHolder. Finder's +// analogous reload path uses ~30s; we mirror that value. +const DefaultDrainGrace = 30 * time.Second + +// SnapshotHolder swaps a SnapshotReader under load without unmapping pages +// out from under in-flight Get()s. Set retains the previous reader behind a +// drain timer; after the timer elapses the reaper goroutine closes the old +// reader (and runs its munmap). +type SnapshotHolder struct { + current atomic.Pointer[SnapshotReader] + + drainsMu sync.Mutex + drains []*pendingClose + drainGrace time.Duration + + stopCh chan struct{} + once sync.Once +} + +type pendingClose struct { + r *SnapshotReader + expireAt time.Time +} + +// NewSnapshotHolder returns a holder with the given drain grace (the time +// the previous reader lives behind Set before being closed). Zero or +// negative grace falls back to DefaultDrainGrace. +func NewSnapshotHolder(grace time.Duration) *SnapshotHolder { + if grace <= 0 { + grace = DefaultDrainGrace + } + h := &SnapshotHolder{ + drainGrace: grace, + stopCh: make(chan struct{}), + } + go h.sweepLoop() + return h +} + +// Open is shorthand for Set(ReadSnapshotFile(path)). Errors leave the +// holder's current reader unchanged. +func (h *SnapshotHolder) Open(path string) error { + r, err := ReadSnapshotFile(path) + if err != nil { + return err + } + h.Set(r) + return nil +} + +// Reload atomically swaps the current reader for one freshly opened from +// path. The old reader (if any) is queued for close after the drain grace +// elapses; in-flight Get(s) on the old reader continue to work. +func (h *SnapshotHolder) Reload(path string) error { + r, err := ReadSnapshotFile(path) + if err != nil { + return err + } + h.Set(r) + return nil +} + +// Set replaces the current reader with r. The previous reader is queued +// for close after the drain grace elapses. r may be nil to clear. +func (h *SnapshotHolder) Set(r *SnapshotReader) { + if r == nil { + h.current.Store(nil) + return + } + old := h.current.Swap(r) + if old == nil { + return + } + h.drainsMu.Lock() + h.drains = append(h.drains, &pendingClose{ + r: old, + expireAt: time.Now().Add(h.drainGrace), + }) + h.drainsMu.Unlock() +} + +// Get is the concurrent-safe shim over the current reader's Get. Returns +// (zero, false) when the holder is empty. +func (h *SnapshotHolder) Get(sku string) (Projection, bool) { + r := h.current.Load() + if r == nil { + return Projection{}, false + } + return r.Get(sku) +} + +// Len returns the count of the current reader (0 if empty). +func (h *SnapshotHolder) Len() int { + r := h.current.Load() + if r == nil { + return 0 + } + return r.Len() +} + +// Close stops the reaper goroutine and closes the current reader + any +// pending drains without waiting for the grace period. Safe to call +// multiple times. +func (h *SnapshotHolder) Close() error { + var firstErr error + h.once.Do(func() { + close(h.stopCh) + r := h.current.Swap(nil) + if r != nil { + if err := r.Close(); err != nil { + firstErr = err + } + } + h.drainsMu.Lock() + drained := h.drains + h.drains = nil + h.drainsMu.Unlock() + for _, d := range drained { + if err := d.r.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + }) + return firstErr +} + +// DrainCount returns the number of readers currently in the drain queue. +// Useful for tests and operational metrics. +func (h *SnapshotHolder) DrainCount() int { + h.drainsMu.Lock() + defer h.drainsMu.Unlock() + return len(h.drains) +} + +// reapNow scans the drain queue and Closes any readers whose grace has +// elapsed. Called by the sweeper goroutine on a tick. +func (h *SnapshotHolder) reapNow() { + now := time.Now() + var alive []*pendingClose + var reaped []*pendingClose + h.drainsMu.Lock() + for _, d := range h.drains { + if !d.expireAt.After(now) { + reaped = append(reaped, d) + } else { + alive = append(alive, d) + } + } + h.drains = alive + h.drainsMu.Unlock() + for _, d := range reaped { + _ = d.r.Close() + } +} + +func (h *SnapshotHolder) sweepLoop() { + interval := h.drainGrace / 4 + if interval < 10*time.Millisecond { + interval = 10 * time.Millisecond + } + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-h.stopCh: + return + case <-t.C: + h.reapNow() + } + } +} diff --git a/catalog/snapshot_manager_test.go b/catalog/snapshot_manager_test.go new file mode 100644 index 0000000..23eaf5b --- /dev/null +++ b/catalog/snapshot_manager_test.go @@ -0,0 +1,177 @@ +package catalog + +import ( + "path/filepath" + "testing" + "time" +) + +// fakeCloser counts how many times it's been called. Used to assert the +// drain-grace semantics of SnapshotHolder. +type fakeCloser struct{ hits int } + +func (f *fakeCloser) Close() error { f.hits++; return nil } + +// withFakeCloser swaps SnapshotReader's closer for our counter. Returns the +// reader and the counter; tests then call holder.Set(reloaded) and assert +// that close is invoked after the drain grace. +func withFakeCloser(t *testing.T, path string, fc *fakeCloser) *SnapshotReader { + t.Helper() + r, err := ReadSnapshotFile(path) + if err != nil { + t.Fatal(err) + } + // replace real closer with our counter; the test asserts only via fc + r.closer = fc.Close + return r +} + +// TestSnapshotHolderReloadDrainsOld — Set swaps the current reader; the +// previous reader is queued. Before grace: still Get-able. After grace: +// reaped and Close fires. +// +// We use a very short grace so the test doesn't sleep long. +func TestSnapshotHolderReloadDrainsOld(t *testing.T) { + dir := t.TempDir() + + // 1) Build a valid first snapshot (skus A-1 / A-2). + w := NewSnapshotWriter() + if err := w.Append(makeProjection("A-1", 100)); err != nil { + t.Fatal(err) + } + if err := w.Append(makeProjection("A-2", 200)); err != nil { + t.Fatal(err) + } + if _, err := w.Write(dir, "first.bin"); err != nil { + t.Fatal(err) + } + firstCloser := &fakeCloser{} + first := withFakeCloser(t, filepath.Join(dir, "first.bin"), firstCloser) + defer first.Close() + + // 2) Build a second snapshot (skus B-1). + w2 := NewSnapshotWriter() + if err := w2.Append(makeProjection("B-1", 300)); err != nil { + t.Fatal(err) + } + if _, err := w2.Write(dir, "second.bin"); err != nil { + t.Fatal(err) + } + secondCloser := &fakeCloser{} + second := withFakeCloser(t, filepath.Join(dir, "second.bin"), secondCloser) + defer second.Close() + + h := NewSnapshotHolder(50 * time.Millisecond) + defer h.Close() + h.Set(first) + if h.Len() != 2 { + t.Errorf("Len after first Set: got %d want 2", h.Len()) + } + if _, ok := h.Get("A-1"); !ok { + t.Error("A-1 not found in initial state") + } + + // 3) Swap to second — first is now queued. + h.Set(second) + if h.Len() != 1 { + t.Errorf("Len after second Set: got %d want 1", h.Len()) + } + if firstCloser.hits != 0 { + t.Errorf("firstCloser hits: got %d want 0 (still in drain grace)", firstCloser.hits) + } + if h.DrainCount() != 1 { + t.Errorf("DrainCount: got %d want 1", h.DrainCount()) + } + + // 4) Wait past grace, force a reap tick. + time.Sleep(80 * time.Millisecond) + h.reapNow() + if firstCloser.hits != 1 { + t.Errorf("firstCloser hits: got %d want 1 (should have reaped)", firstCloser.hits) + } + if h.DrainCount() != 0 { + t.Errorf("DrainCount after reap: got %d want 0", h.DrainCount()) + } +} + +// TestSnapshotHolderEmptyGet — holder with no current serves empty. +func TestSnapshotHolderEmptyGet(t *testing.T) { + h := NewSnapshotHolder(100 * time.Millisecond) + defer h.Close() + if _, ok := h.Get("anything"); ok { + t.Error("empty holder should return false") + } + if h.Len() != 0 { + t.Errorf("Len: got %d want 0", h.Len()) + } +} + +// TestSnapshotHolderCloseImmediatelyReaps — Close with pending drains +// should close them now (no grace wait). Confirms the drain queue is +// drained on Close. +func TestSnapshotHolderCloseImmediatelyReaps(t *testing.T) { + dir := t.TempDir() + + w := NewSnapshotWriter() + if err := w.Append(makeProjection("X-1", 1)); err != nil { + t.Fatal(err) + } + if _, err := w.Write(dir, "x.bin"); err != nil { + t.Fatal(err) + } + fc1 := &fakeCloser{} + r1 := withFakeCloser(t, filepath.Join(dir, "x.bin"), fc1) + + w2 := NewSnapshotWriter() + if err := w2.Append(makeProjection("Y-1", 2)); err != nil { + t.Fatal(err) + } + if _, err := w2.Write(dir, "y.bin"); err != nil { + t.Fatal(err) + } + fc2 := &fakeCloser{} + r2 := withFakeCloser(t, filepath.Join(dir, "y.bin"), fc2) + + h := NewSnapshotHolder(10 * time.Second) // long grace so reap wouldn't happen on its own + h.Set(r1) + h.Set(r2) + + if fc1.hits != 0 || fc2.hits != 0 { + t.Errorf("premature Close hits: fc1=%d fc2=%d", fc1.hits, fc2.hits) + } + if err := h.Close(); err != nil { + t.Errorf("Close: %v", err) + } + if fc1.hits != 1 || fc2.hits != 1 { + t.Errorf("after Close: fc1=%d fc2=%d want 1 each", fc1.hits, fc2.hits) + } +} + +// TestSnapshotHolderIdentityReload — Open the same path twice; current reader +// is replaced; DrainCount returns 1 (old queued). Old's data still served +// until grace elapses. +func TestSnapshotHolderIdentityReload(t *testing.T) { + dir := t.TempDir() + w := NewSnapshotWriter() + if err := w.Append(makeProjection("ID-1", 1)); err != nil { + t.Fatal(err) + } + if _, err := w.Write(dir, "id.bin"); err != nil { + t.Fatal(err) + } + + h := NewSnapshotHolder(10 * time.Second) + defer h.Close() + if err := h.Reload(filepath.Join(dir, "id.bin")); err != nil { + t.Fatal(err) + } + if err := h.Reload(filepath.Join(dir, "id.bin")); err != nil { + t.Fatal(err) + } + if h.DrainCount() != 1 { + t.Errorf("DrainCount after two reloads of same path: got %d want 1", h.DrainCount()) + } + if _, ok := h.Get("ID-1"); !ok { + t.Error("current reader did not surface ID-1") + } +} diff --git a/catalog/snapshot_other.go b/catalog/snapshot_other.go new file mode 100644 index 0000000..996abf9 --- /dev/null +++ b/catalog/snapshot_other.go @@ -0,0 +1,17 @@ +//go:build !unix + +// Non-unix fallback for SnapshotReader: ReadFile into a []byte. Functionally +// identical to the unix mmap path — same reader code, same lookups — just +// pays the full resident-set cost at Open. Production mmap-on-Linux/Darwin; +// this exists for windows / plan9 / wasm builds. +package catalog + +import "os" + +func loadSnapshotBytes(path string) (data []byte, closer func() error, err error) { + data, err = os.ReadFile(path) + if err != nil { + return nil, nil, err + } + return data, func() error { return nil }, nil +} diff --git a/catalog/snapshot_test.go b/catalog/snapshot_test.go new file mode 100644 index 0000000..8dbe066 --- /dev/null +++ b/catalog/snapshot_test.go @@ -0,0 +1,448 @@ +package catalog + +import ( + "encoding/binary" + "errors" + "fmt" + "math" + "math/rand" + "path/filepath" + "testing" + "time" + + "git.k6n.net/mats/platform/money" +) + +// TestSnapshotRoundTrip — write 1000 projections in shuffled order, read back +// and verify all 1000 are found at the right SKU. +func TestSnapshotRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "snap.bin") + + const n = 1000 + w := NewSnapshotWriter().WithTenant("default") + // intentionally shuffled input — sort is what reorders at Write. + perm := rand.Perm(n) + for _, i := range perm { + sku := fmt.Sprintf("SKU-%05d", i) + if err := w.Append(makeProjection(sku, int64(100+(i%97)*100))); err != nil { + t.Fatalf("Append %s: %v", sku, err) + } + } + if w.Len() != n { + t.Fatalf("Len: got %d want %d", w.Len(), n) + } + final, err := w.Write(dir, "snap.bin") + if err != nil { + t.Fatalf("Write: %v", err) + } + if final != path { + t.Errorf("Write returned %q expected %q", final, path) + } + + r, err := ReadSnapshotFile(path) + if err != nil { + t.Fatalf("ReadSnapshotFile: %v", err) + } + defer r.Close() + + if r.Len() != n { + t.Errorf("Len: got %d want %d", r.Len(), n) + } + if r.Tenant != "default" { + t.Errorf("Tenant: got %q want %q", r.Tenant, "default") + } + + // every SKU present and correct + for _, i := range rand.Perm(n)[:50] { // probe 50 random lookups + sku := fmt.Sprintf("SKU-%05d", i) + got, ok := r.Get(sku) + if !ok { + t.Errorf("Get(%q) not found", sku) + continue + } + want := money.Cents(100 + (i%97)*100) + if got.PriceIncVat != want { + t.Errorf("Get(%q) PriceIncVat: got %d want %d", sku, got.PriceIncVat, want) + } + } + + // missing SKU + if _, ok := r.Get("NOPE-99999"); ok { + t.Error("Get on missing SKU should return ok=false") + } +} + +// TestSnapshotCompareSKUInvariant — verify the on-disk record section is +// sorted under the same compareSKU total order that BinarySearch uses. +// If sort.Slice and Reader.Get ever drift apart, lookups silently miss +// — this test pins that invariant. +func TestSnapshotCompareSKUInvariant(t *testing.T) { + dir := t.TempDir() + w := NewSnapshotWriter() + // deliberately inverted order after Append (so sort has work to do) + perm := rand.Perm(500) + for _, i := range perm { + sku := fmt.Sprintf("XX-%05d", i) + if err := w.Append(makeProjection(sku, int64(i))); err != nil { + t.Fatal(err) + } + } + if _, err := w.Write(dir, "snap.bin"); err != nil { + t.Fatal(err) + } + r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin")) + if err != nil { + t.Fatal(err) + } + defer r.Close() + + // Walk every record slot, harvest SKU bytes from the pad-then-trim + // form, and assert non-decreasing order under compareSKU. + prevSKU := "" + for i := 0; i < r.Len(); i++ { + recOff := int(r.recordStart) + i*recordSize + raw := r.data[recOff : recOff+32] + // trim trailing zeros + end := len(raw) + for end > 0 && raw[end-1] == 0 { + end-- + } + cur := string(raw[:end]) + if i > 0 { + if compareSKU(runesToPadded32(prevSKU), []byte(cur)) > 0 { + t.Errorf("record section out of order at i=%d: prev=%q cur=%q", i, prevSKU, cur) + } + } + prevSKU = cur + } +} + +// runesToPadded32 zero-pads s into a 32-byte slot for compareSKU in the +// invariant test. +func runesToPadded32(s string) []byte { + out := make([]byte, 32) + copy(out, s) + return out +} + +// TestSnapshotTenantRoundTrip — Writer.WithTenant lands in the header and +// Reader surfaces it. Without this, one file per tenant is just a convention +// with no on-disk evidence. +func TestSnapshotTenantRoundTrip(t *testing.T) { + dir := t.TempDir() + w := NewSnapshotWriter().WithTenant("bygglagret") + if err := w.Append(makeProjection("ABC-1", 100)); err != nil { + t.Fatal(err) + } + if _, err := w.Write(dir, "snap.bin"); err != nil { + t.Fatal(err) + } + r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin")) + if err != nil { + t.Fatal(err) + } + defer r.Close() + if r.Tenant != "bygglagret" { + t.Errorf("Tenant: got %q want %q", r.Tenant, "bygglagret") + } + + // Empty tenant writes zero-padded. + w2 := NewSnapshotWriter() + if err := w2.Append(makeProjection("ABC-2", 200)); err != nil { + t.Fatal(err) + } + if _, err := w2.Write(dir, "snap2.bin"); err != nil { + t.Fatal(err) + } + r2, err := ReadSnapshotFile(filepath.Join(dir, "snap2.bin")) + if err != nil { + t.Fatal(err) + } + defer r2.Close() + if r2.Tenant != "" { + t.Errorf("Tenant (no tag): got %q want empty", r2.Tenant) + } +} + +// TestSnapshotTenantTooLong — over-long tenant name rejected at Write. +func TestSnapshotTenantTooLong(t *testing.T) { + dir := t.TempDir() + w := NewSnapshotWriter().WithTenant(string(make([]byte, 33))) + if err := w.Append(makeProjection("ABC", 1)); err != nil { + t.Fatal(err) + } + if _, err := w.Write(dir, "snap.bin"); err == nil { + t.Error("expected error for over-long tenant") + } else if !errors.Is(err, ErrTenantTooLong) { + t.Errorf("got %v want ErrTenantTooLong", err) + } +} + +// TestSnapshotCountOverflowRejected — tampered count that would cause +// recordStart + records*recordSize to overflow uint64 must be rejected +// with ErrSnapshotOverflow (formerly a silent wraparound vulnerability). +func TestSnapshotCountOverflowRejected(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "over.bin") + bs := make([]byte, headerSize) + binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) + binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion) + binary.LittleEndian.PutUint32(bs[12:16], 0) + // count that overflows when multiplied by recordSize=44 + binary.LittleEndian.PutUint64(bs[16:24], math.MaxUint64/uint64(recordSize)+1) + binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) + binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) + if err := os2WriteFile(path, bs, 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotOverflow) { + t.Errorf("overflow count rejected with %v, want ErrSnapshotOverflow", err) + } +} + +// TestSnapshotAtomicSwap — verify the temporary-then-rename discipline never +// leaves a half-written snap.bin visible to readers. +func TestSnapshotAtomicSwap(t *testing.T) { + dir := t.TempDir() + w := NewSnapshotWriter() + mustAppend := func(w *SnapshotWriter, sku string, price int64) { + t.Helper() + if err := w.Append(makeProjection(sku, price)); err != nil { + t.Fatalf("Append %s: %v", sku, err) + } + } + mustAppend(w, "AAA", 100) + final, err := w.Write(dir, "snap.bin") + if err != nil { + t.Fatal(err) + } + // tmp must not exist anymore + if _, err := os2Stat(final + ".tmp"); err == nil { + t.Errorf(".tmp still present after Write") + } + + // replace + w2 := NewSnapshotWriter() + mustAppend(w2, "ZZZ", 200) + if _, err := w2.Write(dir, "snap.bin"); err != nil { + t.Fatal(err) + } + + r, err := ReadSnapshotFile(final) + if err != nil { + t.Fatal(err) + } + defer r.Close() + if _, ok := r.Get("ZZZ"); !ok { + t.Error("second-snapshot Get(ZZZ) missing") + } + if _, ok := r.Get("AAA"); ok { + t.Error("first-snapshot residue Get(AAA) found — atomic swap bug") + } +} + +// TestSnapshotRejectBadInput — magic, version, truncation, blob-offset OOB. +func TestSnapshotRejectBadInput(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.bin") + + // Empty file: truncated. + if err := os2WriteFile(path, []byte{}, 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotTruncated) { + t.Errorf("empty file rejected with %v, want ErrSnapshotTruncated", err) + } + + // Wrong magic. The fixture must be at least headerSize bytes long + // (v2 header is 72 B), otherwise we'd hit ErrSnapshotTruncated + // before the magic check fires. + wrongMagic := make([]byte, headerSize+1) + for i := range wrongMagic { + wrongMagic[i] = 0xff + } + if err := os2WriteFile(path, wrongMagic, 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotMagic) { + t.Errorf("wrong magic rejected with %v, want ErrSnapshotMagic", err) + } + + // Truncated mid-record. A valid header claiming 5 records but with no + // record section. + if err := os2WriteFile(path, buildValidHeader(5), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotTruncated) { + t.Errorf("truncated snapshot rejected with %v, want ErrSnapshotTruncated", err) + } + + // Tampered blob offsets: valid header, valid record section with a + // blob pointer that lies past EOF. Should reject with ErrSnapshotBadBlob. + tampered := buildTamperedHeader() + if err := os2WriteFile(path, tampered, 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotBadBlob) { + t.Errorf("tampered-blob rejected with %v, want ErrSnapshotBadBlob", err) + } +} + +// TestSnapshotEmpty — empty (count=0) snapshot is valid; Get returns false. +func TestSnapshotEmpty(t *testing.T) { + dir := t.TempDir() + w := NewSnapshotWriter() + if _, err := w.Write(dir, "empty.bin"); err != nil { + t.Fatal(err) + } + r, err := ReadSnapshotFile(filepath.Join(dir, "empty.bin")) + if err != nil { + t.Fatal(err) + } + defer r.Close() + if r.Len() != 0 { + t.Errorf("Len: got %d want 0", r.Len()) + } + if _, ok := r.Get("anything"); ok { + t.Error("empty snapshot should never return ok=true") + } +} + +// TestSnapshotSKU32 — SKU exactly 32 chars (the boundary case) must succeed. +func TestSnapshotSKU32(t *testing.T) { + dir := t.TempDir() + sku := "SKU-" + repeat32("X", 32-4) // 4 + 28 = 32 chars + if len(sku) != 32 { + t.Fatalf("test bug: sku length %d", len(sku)) + } + w := NewSnapshotWriter() + if err := w.Append(makeProjection(sku, 1)); err != nil { + t.Fatalf("Append 32-char SKU: %v", err) + } + if _, err := w.Write(dir, "snap.bin"); err != nil { + t.Fatal(err) + } + r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin")) + if err != nil { + t.Fatal(err) + } + defer r.Close() + if _, ok := r.Get(sku); !ok { + t.Error("32-char SKU Get returned false") + } +} + +// TestSnapshotSKU33Rejected — one char over should error at Append. +func TestSnapshotSKU33Rejected(t *testing.T) { + w := NewSnapshotWriter() + sku := repeat32("X", 33) + err := w.Append(makeProjection(sku, 1)) + if !errors.Is(err, ErrSKUTooLong) { + t.Errorf("33-char SKU Append: got %v want ErrSKUTooLong", err) + } +} + +// TestSnapshotGetSpeed — probe thousands of random SKU lookups to give a +// sense of the binary-search cost (not a benchmark per se). +func TestSnapshotGetSpeed(t *testing.T) { + if testing.Short() { + t.Skip("short mode") + } + dir := t.TempDir() + const n = 5000 + w := NewSnapshotWriter() + skus := make([]string, n) + for i := 0; i < n; i++ { + skus[i] = fmt.Sprintf("Z%06d", i) + if err := w.Append(makeProjection(skus[i], int64(i))); err != nil { + t.Fatalf("Append %s: %v", skus[i], err) + } + } + if _, err := w.Write(dir, "snap.bin"); err != nil { + t.Fatal(err) + } + r, err := ReadSnapshotFile(filepath.Join(dir, "snap.bin")) + if err != nil { + t.Fatal(err) + } + defer r.Close() + + start := time.Now() + hits, misses := 0, 0 + for _, i := range rand.Perm(n) { + if _, ok := r.Get(skus[i]); ok { + hits++ + } else { + misses++ + } + } + elapsed := time.Since(start) + t.Logf("100%% hit Get on %d records: %d hits, %d misses, %v (%v/lookup)", + n, hits, misses, elapsed, elapsed/time.Duration(n)) + if misses != 0 { + t.Errorf("expected zero misses on full-key set, got %d", misses) + } +} + +// buildValidHeader synthesises a header byte slice with the given Count +// and zero-blob / zero-record section, used to exercise the truncation +// branch in newSnapshotReader. +func buildValidHeader(count uint64) []byte { + bs := make([]byte, headerSize) + binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) + binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion) + binary.LittleEndian.PutUint32(bs[12:16], 0) + binary.LittleEndian.PutUint64(bs[16:24], count) + binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) // blobStart + binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) // recordStart (truncated) + return bs +} + +// buildTamperedHeader returns a buffer with a valid header claiming 1 record +// AND its 44-byte record section, but the record's blob pointer lies past +// EOF. Used to exercise ErrSnapshotBadBlob. +func buildTamperedHeader() []byte { + bs := make([]byte, headerSize+recordSize) + binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) + binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion) + binary.LittleEndian.PutUint32(bs[12:16], 0) + binary.LittleEndian.PutUint64(bs[16:24], 1) + binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) + binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) + binary.LittleEndian.PutUint64(bs[headerSize+32:headerSize+40], ^uint64(0)) + binary.LittleEndian.PutUint32(bs[headerSize+40:headerSize+44], ^uint32(0)) + return bs +} + +// TestSnapshotRejectBadVersion — pin the ErrSnapshotVersion rejection path. +func TestSnapshotRejectBadVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "badver.bin") + bs := make([]byte, headerSize) + binary.LittleEndian.PutUint64(bs[0:8], snapshotMagic) + binary.LittleEndian.PutUint32(bs[8:12], snapshotVersion+1) + binary.LittleEndian.PutUint32(bs[12:16], 0) + binary.LittleEndian.PutUint64(bs[16:24], 0) + binary.LittleEndian.PutUint64(bs[24:32], uint64(headerSize)) + binary.LittleEndian.PutUint64(bs[32:40], uint64(headerSize)) + if err := os2WriteFile(path, bs, 0o644); err != nil { + t.Fatal(err) + } + if _, err := ReadSnapshotFile(path); !errors.Is(err, ErrSnapshotVersion) { + t.Errorf("v2-not-supported header rejected with %v, want ErrSnapshotVersion", err) + } +} + +// makeProjection helper repeated here for the new tests. +func makeProjection(sku string, price int64) Projection { + return Projection{ + ID: "sku:" + sku, + SKU: sku, + Title: "Item " + sku, + PriceIncVat: money.Cents(price), + Currency: "SEK", + TaxClass: "standard", + UpdatedAt: time.Now().UnixMilli(), + } +} diff --git a/catalog/snapshot_unix.go b/catalog/snapshot_unix.go new file mode 100644 index 0000000..7fb0e04 --- /dev/null +++ b/catalog/snapshot_unix.go @@ -0,0 +1,61 @@ +//go:build unix + +// mmap-on-unix loader for SnapshotReader. The file handle stays alive for +// the lifetime of the mmap region (the kernel needs it pinned). Closer +// munmaps AND closes the fd. Uses MADV_RANDOM because the dominant access +// pattern is binary-search-by-SKU — touching random record slots, not +// streaming the file. +package catalog + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +// loadSnapshotBytes returns the file contents (mmap'd) + a closer that munmaps +// and closes the fd. +func loadSnapshotBytes(path string) (data []byte, closer func() error, err error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + info, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, nil, err + } + size := info.Size() + if size == 0 { + _ = f.Close() + return []byte{}, func() error { return nil }, nil + } + if size < int64(headerSize) { + _ = f.Close() + return nil, nil, ErrSnapshotTruncated + } + + data, mmapErr := unix.Mmap(int(f.Fd()), 0, int(size), unix.PROT_READ, unix.MAP_SHARED) + if mmapErr != nil { + _ = f.Close() + return nil, nil, fmt.Errorf("catalog: mmap %s: %w", path, mmapErr) + } + + // Binary-search by SKU = random page access; MADV_RANDOM tells the + // kernel NOT to do read-ahead — saves I/O on cold layers. + if maErr := unix.Madvise(data, unix.MADV_RANDOM); maErr != nil { + // non-fatal; degrade gracefully + _ = maErr + } + + closer = func() error { + mErr := unix.Munmap(data) + cErr := f.Close() + if mErr != nil { + return mErr + } + return cErr + } + return data, closer, nil +} diff --git a/cmd/catalog-emit/main.go b/cmd/catalog-emit/main.go new file mode 100644 index 0000000..0a4f768 --- /dev/null +++ b/cmd/catalog-emit/main.go @@ -0,0 +1,571 @@ +// Package main — `catalog-emit` is the producer-side harness for the +// platform/catalog wire. It has two modes: +// +// 1. One-shot (no -rabbit flag): synthesise / load items per tenant, write +// one snapshot file per tenant under -out-dir, probe a randomised sample +// of SKUs to verify the format round-trips, then exit. Useful for +// seed-and-verify runs and CI. +// +// 2. Watch (-rabbit amqp://...): emit a baseline snapshot per tenant (so the +// start state matches what the bus will then incrementally update), bind +// to the AMQP topic exchange and consume `catalog.projection_published` +// events. Each event carries a `[]ProjectionUpdate` batch (upsert+delete +// per SKU). Per-tenant live state is reflected in a per-tenant +// SnapshotHolder; reload-on-event uses the same drain-grace atomic-swap +// pattern as the consumer side. A periodic probe validates that live Get +// traffic continues to hit under concurrent reloads. +// +// Note on naming: "producer wire-up" here means the harness produces per-tenant +// snapshot BIN files autosaved on every bus batch — it does NOT publish +// catalog.projection_published events itself (it only consumes them, in the +// same role as cart / checkout / order). +// +// Usage (one-shot): +// +// catalog-emit \ +// -schemas /etc/platform/catalog/schemas.json \ +// [-items ./items.json] \ +// -out-dir ./catalog \ +// -tenants default[,bygglagret] +// +// Usage (watch): +// +// catalog-emit \ +// -schemas /etc/platform/catalog/schemas.json \ +// -rabbit amqp://guest:guest@localhost:5672/ \ +// -exchange platform \ +// -out-dir ./catalog \ +// -tenants default[,bygglagret] +// +// Exit code 0 = every sampled lookup matched; 2 = bad CLI args; 1 = a probe +// miss happened in one-shot mode (in watch mode, misses are reported but the +// process keeps running for further evaluation). +package main + +import ( + "encoding/json" + "flag" + "fmt" + "math/rand" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "git.k6n.net/mats/platform/catalog" + "git.k6n.net/mats/platform/cmd/internal/catalogtest" + "git.k6n.net/mats/platform/event" + "git.k6n.net/mats/platform/rabbit" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// TenantState is one tenant's live projection state + a SnapshotHolder that +// hot-reloads a freshly-written snapshot file on every update batch. +// +// The items map is the live projection set; rebuilding it from a snapshot +// is intentionally cheap because the written file IS the on-disk truth +// after every rebuild — but the live map is what bus batches merge into. +// +// Counters are atomic.Uint64 because they are read by the SIGINT shutdown +// path concurrently with writes from both the bus-handler goroutine and +// the probe goroutine; a mutex-protected uint64 would either contend on +// the probe hot path or race with the shutdown summary. +type TenantState struct { + tenant string + schema catalog.Schema + + mu sync.RWMutex + items map[string]catalog.Projection + + holder *catalog.SnapshotHolder + path string // /.bin + + rebuildCount atomic.Uint64 + probeCount atomic.Uint64 + probeMisses atomic.Uint64 +} + +// newTenantState wires a brand-new tenant. Caller is responsible for +// calling rebuild() (or Open via the initial state pipeline) before any Get. +func newTenantState(tenant string, sch catalog.Schema, holder *catalog.SnapshotHolder, path string) *TenantState { + return &TenantState{ + tenant: tenant, + schema: sch, + items: make(map[string]catalog.Projection, 1024), + holder: holder, + path: path, + } +} + +// rebuild merges batch atomically under the write lock, captures a snapshot +// of the items map, releases the lock, and does IO (Write + Reload) without +// holding it. The snapshot capture is the only step that touches the items +// map; everything after the unlock races freely with concurrent +// rebuilds / probes, and drain-grace handles the prior-reader lifecycle. +// +// Race analysis: between unlock and Write completion, a second event for +// the same tenant could mutate items and trigger a parallel rebuild that +// races to the disk. Resolve in favour of the second event — its Write +// wins via atomic-rename; the first Write either finishes last (writes the +// older state, harmless because items already moved) or its tmp file is +// overwritten by the second's tmp + rename. The fact that the second's +// reload is the latest Set means downstream Get sees the newest state; +// the first's Reload, if it lands second, demotes a newer reader to +// older — drain-grace then queues the newer one for reap after grace, +// and the older one serves drops via atomic.Pointer swap. Both ends are +// eventually consistent; not a correctness break, an arrival-ordering race +// in IO. For a producer harness verification role this is acceptable; a +// high-volume producer should use a per-tenant coalescing channel. +func (ts *TenantState) rebuild(batch []catalog.ProjectionUpdate) error { + ts.mu.Lock() + if batch != nil { + for _, u := range batch { + if u.Deleted { + delete(ts.items, u.SKU) + continue + } + if u.SKU == "" { + continue + } + ts.items[u.SKU] = u.Projection + } + } + snapshot := make([]catalog.Projection, 0, len(ts.items)) + for _, p := range ts.items { + snapshot = append(snapshot, p) + } + ts.rebuildCount.Add(1) + ts.mu.Unlock() + + return ts.writeSnapshot(snapshot) +} + +// writeSnapshot emits snapshot to disk (atomic temp+rename) and reloads +// the holder. Called WITHOUT holding ts.mu. +func (ts *TenantState) writeSnapshot(snap []catalog.Projection) error { + w := catalog.NewSnapshotWriter().WithTenant(ts.tenant) + for _, p := range snap { + if err := w.Append(p); err != nil { + return fmt.Errorf("append %s: %w", p.SKU, err) + } + } + out, err := w.Write(filepath.Dir(ts.path), filepath.Base(ts.path)) + if err != nil { + return fmt.Errorf("snapshot write %s: %w", ts.path, err) + } + if err := ts.holder.Reload(out); err != nil { + return fmt.Errorf("holder reload %s: %w", out, err) + } + return nil +} + +// upsert inserts or replaces a projection in the live map. Used by the +// initial-seed pipeline; the bus-update path goes through rebuild(). +func (ts *TenantState) upsert(p catalog.Projection) { + ts.mu.Lock() + defer ts.mu.Unlock() + ts.items[p.SKU] = p +} + +// count returns the current item-set size under read lock. +func (ts *TenantState) count() int { + ts.mu.RLock() + defer ts.mu.RUnlock() + return len(ts.items) +} + +// sample uniformly picks up to n SKUs at random from the live map. Used by +// the probe paths to validate atomic-reload under drain-grace. +func (ts *TenantState) sample(rng *rand.Rand, n int) []string { + ts.mu.RLock() + defer ts.mu.RUnlock() + if len(ts.items) == 0 { + return nil + } + skus := make([]string, 0, len(ts.items)) + for sku := range ts.items { + skus = append(skus, sku) + } + if n >= len(skus) { + return skus + } + out := make([]string, 0, n) + picked := make(map[int]struct{}, n) + for len(out) < n { + idx := rng.Intn(len(skus)) + if _, dup := picked[idx]; dup { + continue + } + picked[idx] = struct{}{} + out = append(out, skus[idx]) + } + return out +} + +// get is a thin wrapper around holder.Get. Counts probes and misses +// atomically — readable from the SIGINT shutdown path without a lock. +func (ts *TenantState) get(sku string) (catalog.Projection, bool) { + ts.probeCount.Add(1) + p, ok := ts.holder.Get(sku) + if !ok { + ts.probeMisses.Add(1) + } + return p, ok +} + +func main() { + schemasPath := flag.String("schemas", "", "JSON file mapping tenant -> catalog.Schema") + itemsPath := flag.String("items", "", "JSON file mapping tenant -> []source-item (optional; loadOrSynthesize falls back to -n synth per tenant if absent)") + outDir := flag.String("out-dir", "./out", "output directory; per-tenant files /.bin") + sampleCount := flag.Int("sample", 200, "number of random SKUs to verify per probe call") + probeTick := flag.Duration("probe-tick", 2*time.Second, "watch mode: probe interval; one-shot ignores it") + tenantsCSV := flag.String("tenants", "default", "comma-separated tenant names to operate on") + emitN := flag.Int("n", 10000, "synthesised item count per tenant when -items is omitted") + seed := flag.Int64("seed", 42, "RNG seed for synthesised items / probe sample") + rabbitURL := flag.String("rabbit", "", "if set, watch mode consumes catalog.projection_published from this RabbitMQ URL") + exchange := flag.String("exchange", "catalog", "RabbitMQ topic exchange to bind on (default 'catalog' to match the writeradmin event family)") + drainGrace := flag.Duration("drain-grace", catalog.DefaultDrainGrace, "SnapshotHolder drain grace (override for tight tests)") + flag.Parse() + + if *schemasPath == "" { + fmt.Fprintln(os.Stderr, "ERROR: -schemas is required") + flag.Usage() + os.Exit(2) + } + + tenants := splitCSV(*tenantsCSV) + if len(tenants) == 0 { + fail("no tenants parsed from -tenants %q", *tenantsCSV) + } + + reg := catalog.NewRegistry() + if err := reg.LoadFile(*schemasPath); err != nil { + fail("load schemas: %v", err) + } + + if err := os.MkdirAll(*outDir, 0o755); err != nil { + fail("mkdir out-dir %q: %v", *outDir, err) + } + + // Per-tenant state. Built once and shared across seed/bus/probe paths. + states := make(map[string]*TenantState, len(tenants)) + for _, t := range tenants { + sch := reg.SchemaFor(t) + if sch.Tenant == "" { + sch.Tenant = t + } + path := filepath.Join(*outDir, t+".bin") + holder := catalog.NewSnapshotHolder(*drainGrace) + states[t] = newTenantState(t, sch, holder, path) + } + // Register cleanup for both the defer path (graceful shutdown / SIGINT) + // and the fail path (mid-run error). Defers run LIFO: rc.Close() (added + // later, in watch mode) fires first; this hook runs second. With rc + // nil, the extra teardown is harmless. + cleanup = func() { + for _, ts := range states { + _ = ts.holder.Close() + } + } + defer cleanup() + // Watch-mode rc is appended after this point; in watch its Close is + // added to the defer stack too, separately. + + // Initial state pipeline: synthesise / load items, Apply per tenant's + // registered schema, upsert into each tenant's map, then rebuild() the + // snapshots so the SnapshotHolders are populated before any Get. + sources, err := catalogtest.LoadOrSynthesize(*itemsPath, tenants, *emitN, *seed, reg, "B") + if err != nil { + fail("load items: %v", err) + } + skipped := 0 + for _, t := range tenants { + sch := reg.SchemaFor(t) + ts := states[t] + for _, src := range sources[t] { + p, err := catalog.Apply(sch, src) + if err != nil { + fmt.Fprintf(os.Stderr, "skip %s: %v\n", t, err) + skipped++ + continue + } + ts.upsert(p) + } + } + for _, t := range tenants { + ts := states[t] + if err := ts.rebuild(nil); err != nil { + fail("initial rebuild %s: %v", t, err) + } + fmt.Printf("initial %s: %d items -> %s\n", t, ts.holder.Len(), ts.path) + } + fmt.Printf("seeded %d items across %d tenants, skipped %d\n", sumCounts(states), len(tenants), skipped) + + watch := *rabbitURL != "" + + if !watch { + if err := probe(states, tenants, *sampleCount, *seed+1); err != nil { + fail("probe miss: %v", err) + } + return + } + + // Watch mode. + fmt.Printf("connecting to RabbitMQ: %s (exchange=%s)\n", *rabbitURL, *exchange) + rc, err := rabbit.Dial(*rabbitURL, "catalog-emit") + if err != nil { + fail("rabbit dial: %v", err) + } + defer rc.Close() + + pattern := string(event.CatalogProjectionPublished) + bindOnce := func() { + ch, err := rc.Channel() + if err != nil { + fmt.Fprintf(os.Stderr, "watch: open channel: %v\n", err) + return + } + if err := rabbit.ListenToPattern(ch, *exchange, pattern, makeHandler(states)); err != nil { + fmt.Fprintf(os.Stderr, "watch: bind pattern %q: %v\n", pattern, err) + _ = ch.Close() + } + } + rc.NotifyOnReconnect(bindOnce) + + fmt.Printf("watching %q on exchange %q\n", pattern, *exchange) + + go probeLoop(states, tenants, *sampleCount, *probeTick) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + fmt.Printf("received signal %s; shutting down (rebuilds=%d probes=%d misses=%d)\n", + sig, + sumRebuilds(states), + sumProbes(states), + sumMisses(states), + ) +} + +// routedEvent is the result of routeTenant — `tenant` is the key into the +// states map; `fallback` tells the handler it should warn (a real +// default-tenant publisher via Meta["tenant"]="default" does NOT set +// fallback, so legitimate default traffic stays silent). +type routedEvent struct { + tenant string + fallback bool // no Meta[tenant] and no Source "catalog:" prefix +} + +// makeHandler returns the AMQP delivery handler that consumes +// `catalog.projection_published`. It parses the event, picks the tenant +// (Meta["tenant"] -> Source[:N] suffix -> "default"), decodes the +// ProjectionUpdate batch, and feeds it to the per-tenant rebuild path. +// +// A deferred recover wraps the body so a panic in any single event +// doesn't kill the consumer goroutine; the queue keeps draining on the +// next delivery, and the reconnect loop re-binds cleanly on the next +// broker hiccup. +func makeHandler(states map[string]*TenantState) func(amqp.Delivery) error { + return func(d amqp.Delivery) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("handler panic recovered: %v", r) + } + }() + var ev event.Event + if err := json.Unmarshal(d.Body, &ev); err != nil { + return fmt.Errorf("decode event: %w", err) + } + r := routeTenant(&ev) + if r.fallback { + // Loud-warn so a misrouted publisher shows up in ops logs. + fmt.Fprintf(os.Stderr, + "watch WARN: event id=%s source=%s type=%s — no Meta[%q] and no \"catalog:\" Source prefix — routing to default\n", + ev.ID, ev.Source, ev.Type, "tenant") + } + ts, ok := states[r.tenant] + if !ok { + // The publisher-decorated tenant exists but isn't in this + // harness's -tenants list. The event is silently dropped — a + // half-configured test environment would look like a broken + // wire. Surface loudly so the misconfig is visible from + // stdout. + fmt.Fprintf(os.Stderr, + "watch WARN: event id=%s source=%s type=%s routed to tenant=%q but this harness only tracks %v — dropping (check -tenants on the harness vs Meta[tenant]/Meta[country] on the publisher)\n", + ev.ID, ev.Source, ev.Type, r.tenant, trackedTenants(states)) + return nil + } + updates, err := catalog.DecodeUpdates(ev.Payload) + if err != nil { + return fmt.Errorf("decode payload as []ProjectionUpdate: %w", err) + } + if err := ts.rebuild(updates); err != nil { + return fmt.Errorf("rebuild %s: %w", r.tenant, err) + } + return nil + } +} + +// routeTenant extracts the tenant for a delivered event. Meta and Source +// values are TrimSpace'd so a publisher that emits " default " or +// "catalog: bygglagret " still lands in the right bucket. Lookup order: +// +// 1. Meta["tenant"] — explicit per-event tenant (preferred). +// 2. Meta["country"] — compat fallback for finder writeradmin today +// (single-tenant-per-country writers). +// 3. Source split — "catalog:" → tenant portion. +// 4. "default" — final fallback so an undecorated publisher still lands +// somewhere the harness can see it. Marked via `fallback: true` so the +// caller can log a warning, distinguishing a real default-tenant +// publisher from a missing-decoration one. +func routeTenant(ev *event.Event) routedEvent { + if t, ok := ev.Meta["tenant"]; ok { + if t = strings.TrimSpace(t); t != "" { + return routedEvent{tenant: t} + } + } + if t, ok := ev.Meta["country"]; ok { + // Legacy finder writers (writeradmin today) only know country. Treat + // country as tenant in the single-tenant-per-country model; multi- + // tenant writers should set Meta["tenant"] explicitly. + if t = strings.TrimSpace(t); t != "" { + return routedEvent{tenant: t} + } + } + if strings.HasPrefix(ev.Source, "catalog:") { + if t := strings.TrimSpace(strings.TrimPrefix(ev.Source, "catalog:")); t != "" { + return routedEvent{tenant: t} + } + } + return routedEvent{tenant: "default", fallback: true} +} + +// trackedTenants returns the sorted list of tenant names configured on +// this harness. Used in misroute WARN lines so a half-configured test +// environment is visible from stdout. +func trackedTenants(states map[string]*TenantState) []string { + out := make([]string, 0, len(states)) + for t := range states { + out = append(out, t) + } + return out +} + +// sumCounts / sumRebuilds / sumProbes / sumMisses aggregate per-tenant +// counters for ops visibility at shutdown. Reads are atomic; safe to call +// concurrently with rebuild() / get(). +func sumCounts(states map[string]*TenantState) int { + n := 0 + for _, ts := range states { + n += ts.count() + } + return n +} + +func sumRebuilds(states map[string]*TenantState) uint64 { + var n uint64 + for _, ts := range states { + n += ts.rebuildCount.Load() + } + return n +} + +func sumProbes(states map[string]*TenantState) uint64 { + var n uint64 + for _, ts := range states { + n += ts.probeCount.Load() + } + return n +} + +func sumMisses(states map[string]*TenantState) uint64 { + var n uint64 + for _, ts := range states { + n += ts.probeMisses.Load() + } + return n +} + +// probe picks `sampleCount` random SKUs across all tenanted state and +// looks them up via the SnapshotHolder. Misses are reported and accumulated; +// on a miss, an error is returned so the caller can fail loud (one-shot) +// or log (watch). +func probe(states map[string]*TenantState, tenants []string, sampleCount int, seed int64) error { + rng := rand.New(rand.NewSource(seed)) + misses := uint64(0) + hits := uint64(0) + start := time.Now() + for _, t := range tenants { + ts := states[t] + skus := ts.sample(rng, sampleCount) + for _, sku := range skus { + if _, ok := ts.get(sku); ok { + hits++ + } else { + fmt.Fprintf(os.Stderr, "MISS: %s/%s\n", t, sku) + misses++ + } + } + } + elapsed := time.Since(start) + total := int(hits + misses) + if total == 0 { + return nil + } + fmt.Printf("probe: %d hits, %d misses, %v (%v/lookup)\n", + hits, misses, elapsed, elapsed/time.Duration(total)) + if misses > 0 { + return fmt.Errorf("%d probe misses", misses) + } + return nil +} + +// probeLoop runs probe() on every tick; logs misses but does NOT exit. It +// is the watch-mode heartbeat that validates the holder under live reloads. +func probeLoop(states map[string]*TenantState, tenants []string, sampleCount int, tick time.Duration) { + t := time.NewTicker(tick) + defer t.Stop() + seed := int64(1) + for range t.C { + _ = probe(states, tenants, sampleCount, seed) + seed++ + } +} + +// splitCSV trims and splits a comma-separated tenant list into a slice. +func splitCSV(s string) []string { + if s == "" { + return nil + } + out := make([]string, 0, 2) + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// cleanup runs the registered shutdown hooks (holder file mmap unmaps, AMQP +// connection close) before exit. main() populates `cleanup` with a function +// that closes all per-tenant SnapshotHolders and rc.Close if rc was opened; +// fail() invokes the same hook before os.Exit so a mid-run failure does not +// leak mmap mappings or AMQP sockets. +// +// We can't just rely on defers because os.Exit skips them — and the holder +// close is the only defer that's registered before several possible fail() +// sites (initial rebuild, one-shot probe miss). +var cleanup = func() {} + +// fail prints a FATAL line, runs registered cleanup hooks, then exits 1. +func fail(format string, a ...any) { + fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", a...) + cleanup() + os.Exit(1) +} diff --git a/cmd/event-emit-publisher/main.go b/cmd/event-emit-publisher/main.go new file mode 100644 index 0000000..026290d --- /dev/null +++ b/cmd/event-emit-publisher/main.go @@ -0,0 +1,314 @@ +// Package main — `event-emit-publisher` is the test-producer for the +// catalog.projection_published wire. It drives the same shape the +// slask-finder writeradmin emits, so cmd/catalog-emit (consumer harness) +// and go-cart-actor/inventory (consumer bridge) can be exercised +// end-to-end without a live writer admin in the loop. +// +// Two modes: +// +// 1. Tick (default): every -tick interval, build a batch of -batch-size +// upserts (mixed with a small fraction of deletes for the cart bridge to +// see), publish one catalog.projection_published event per tenant, +// repeat. +// 2. One-shot (-oneshot): publish a single batch and exit — useful for CI +// and one-line smoke checks. +// +// Usage: +// +// event-emit-publisher \ +// -schemas /etc/platform/catalog/schemas.json \ +// -tenants default,bygglagret[,se] \ +// -rabbit amqp://admin:devpass@localhost:5672/ \ +// -exchange catalog \ +// -batch-size 200 -tick 5s -jitter 1s [-oneshot] +package main + +import ( + "flag" + "fmt" + "math/rand" + "os" + "os/signal" + "strconv" + "strings" + "sync/atomic" + "syscall" + "time" + + "git.k6n.net/mats/platform/catalog" + "git.k6n.net/mats/platform/cmd/internal/catalogtest" + "git.k6n.net/mats/platform/event" + "git.k6n.net/mats/platform/rabbit" +) + +// perTenantPublisher holds the batches+counter for one tenant so we can +// emit one event per tenant per tick without rebuilding the schema work. +type perTenantPublisher struct { + tenant string + schema catalog.Schema + count atomic.Uint64 // batches published for this tenant +} + +func main() { + schemasPath := flag.String("schemas", "", "JSON file mapping tenant -> catalog.Schema") + itemsPath := flag.String("items", "", "JSON file mapping tenant -> []source-item (optional; falls back to synthetic items when absent)") + tenantsCSV := flag.String("tenants", "default", "comma-separated tenant names (also used as Meta[tenant]") + emitN := flag.Int("n", 10000, "synthesised total item count per tenant when -items is omitted") + batchSize := flag.Int("batch-size", 100, "items per published batch per tenant") + deleteRatio := flag.Float64("delete-ratio", 0.05, "fraction of each batch marked deleted (0..1)") + rabbitURL := flag.String("rabbit", "amqp://admin:devpass@localhost:5672/", "RabbitMQ URL") + exchange := flag.String("exchange", "catalog", "RabbitMQ topic exchange to publish on (default 'catalog')") + tickDuration := flag.Duration("tick", 5*time.Second, "interval between batches (0 disables ticking)") + jitter := flag.Duration("jitter", 1*time.Second, "max random delay added on top of tick") + seed := flag.Int64("seed", 42, "RNG seed") + oneshot := flag.Bool("oneshot", false, "publish a single batch per tenant and exit") + maxBatches := flag.Int("max-batches", 0, "stop after this many batches per tenant (0 = unlimited)") + eventIDPrefix := flag.String("event-id-prefix", "pub-", "prefix for emitted event IDs") + flag.Parse() + + if *schemasPath == "" { + fmt.Fprintln(os.Stderr, "ERROR: -schemas is required") + flag.Usage() + os.Exit(2) + } + if *batchSize <= 0 { + fail("batch-size must be > 0, got %d", *batchSize) + } + if *deleteRatio < 0 || *deleteRatio > 1 { + fail("delete-ratio must be in [0,1], got %v", *deleteRatio) + } + + tenants := splitCSV(*tenantsCSV) + if len(tenants) == 0 { + fail("no tenants parsed from -tenants %q", *tenantsCSV) + } + + reg := catalog.NewRegistry() + if err := reg.LoadFile(*schemasPath); err != nil { + fail("load schemas: %v", err) + } + + sources, err := catalogtest.LoadOrSynthesize(*itemsPath, tenants, *emitN, *seed, reg, "P") + if err != nil { + fail("load items: %v", err) + } + + // Build per-tenant schema and pre-applied projections once (the cart + // and the harness hot path both want the typed Projection shape). + pubs := make([]*perTenantPublisher, 0, len(tenants)) + for _, t := range tenants { + sch := reg.SchemaFor(t) + if sch.Tenant == "" { + sch.Tenant = t + } + if _, ok := sources[t]; !ok { + fail("tenant %q has no source items (check -tenants and -items / -n)", t) + } + pubs = append(pubs, &perTenantPublisher{tenant: t, schema: sch}) + } + + // Dial RabbitMQ. We use the rabbit.Conn wrapper so the publisher can run + // across broker hiccups. + rc, err := rabbit.Dial(*rabbitURL, "event-emit-publisher") + if err != nil { + fail("rabbit dial %q: %v", *rabbitURL, err) + } + // Register cleanup so a later fail() won't leak the AMQP socket, and + // also so graceful shutdown closes the connection cleanly. + cleanup = func() { _ = rc.Close() } + defer cleanup() + + if err := declareExchange(rc, *exchange); err != nil { + fail("declare exchange %q: %v", *exchange, err) + } + fmt.Printf("publishing to %s on exchange %q (tenants=%v batch=%d tick=%v jitter=%v oneshot=%v)\n", + *rabbitURL, *exchange, tenants, *batchSize, *tickDuration, *jitter, *oneshot) + + rng := rand.New(rand.NewSource(*seed)) + + publishOnce := func() { + for _, pub := range pubs { + updates, skipped := buildBatch(pub, sources[pub.tenant], *batchSize, *deleteRatio, rng) + ev := buildEvent(pub, updates, *eventIDPrefix) + if err := publishEvent(rc, *exchange, ev); err != nil { + fmt.Fprintf(os.Stderr, "publish %s: %v\n", pub.tenant, err) + continue + } + n := pub.count.Add(1) + fmt.Printf("tenant=%s batch=%d upserts=%d skipped=%d eventID=%s\n", + pub.tenant, n, len(updates), skipped, ev.ID) + } + } + + if *oneshot { + publishOnce() + return + } + + if *tickDuration <= 0 { + fail("watch-mode requires -tick > 0 (use -oneshot to emit one batch and exit)") + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Fprintln(os.Stderr, "received signal; shutting down") + // bulk-total counter for the SIGINT summary + for _, pub := range pubs { + fmt.Printf("summary tenant=%s batches=%d\n", pub.tenant, pub.count.Load()) + } + os.Exit(0) + }() + + t := time.NewTicker(*tickDuration) + defer t.Stop() + batches := 0 + for { + select { + case <-t.C: + publishOnce() + batches++ + if *maxBatches > 0 && batches >= *maxBatches { + fmt.Printf("reached max-batches=%d per tenant, exiting\n", *maxBatches) + return + } + if *jitter > 0 { + // slop the next tick by a small random fraction of the jitter + time.Sleep(time.Duration(rng.Int63n(int64(*jitter)))) + } + } + } +} + +// buildBatch picks batchSize items from sources, applies the schema, and +// returns a []ProjectionUpdate batch (upserts and deletes). deleteRatio of +// items become deletion markers (with the same Schema on apply but +// upsert=false) so the cart bridge sees both shapes. +// +// skipped counts items that failed Apply — leave them out rather than +// poisoning the batch (mirrors writeradmin's behavior). +func buildBatch(pub *perTenantPublisher, sourceItems []map[string]any, batchSize int, deleteRatio float64, rng *rand.Rand) ([]catalog.ProjectionUpdate, int) { + updates := make([]catalog.ProjectionUpdate, 0, batchSize) + skipped := 0 + n := len(sourceItems) + if n == 0 { + return updates, 0 + } + for i := 0; i < batchSize; i++ { + idx := rng.Intn(n) + src := sourceItems[idx] + isDelete := rng.Float64() < deleteRatio + if isDelete { + sku := catalogtest.StringField(src, pub.schema.SKUField) + if sku == "" { + skipped++ + continue + } + updates = append(updates, catalog.ProjectionUpdate{ + Projection: catalog.Projection{ + SKU: sku, + ID: pub.schema.IDPrefix + sku, + }, + Deleted: true, + }) + continue + } + p, err := catalog.Apply(pub.schema, src) + if err != nil { + skipped++ + continue + } + updates = append(updates, catalog.ProjectionUpdate{Projection: p}) + } + return updates, skipped +} + +// buildEvent wraps a batch of updates into one envelope.Event matching the +// publisher conventions used by slask-finder/writeradmin/projection-emit.go. +// +// Source uses the "catalog:" prefix (consumer's routeTenant splits +// on ':'), and Meta carries both "tenant" AND "country" (the latter for +// compat with single-tenant-per-country models). Push a stable unique ID +// so a replay tool can dedupe. +func buildEvent(pub *perTenantPublisher, updates []catalog.ProjectionUpdate, idPrefix string) event.Event { + payload, err := catalog.EncodeUpdates(updates) + if err != nil { + // EncodeUpdates only errors on a fundamentally broken batch; surface loud. + fail("encode updates for %s: %v", pub.tenant, err) + } + return event.Event{ + ID: idPrefix + pub.tenant + "-" + strconv.FormatInt(time.Now().UnixNano(), 36), + Type: event.CatalogProjectionPublished, + Source: "catalog:" + pub.tenant, + Subject: pub.tenant, + Payload: payload, + Time: time.Now(), + Meta: map[string]string{ + "tenant": pub.tenant, + "country": pub.tenant, // single-tenant-per-country model + "emitter": "event-emit-publisher", + }, + } +} + +// publishEvent publishes the envelope via rabbit.PublishEvent (which +// opens a channel per call — fine for a low-rate test publisher). Routing +// key inside PublishEvent is implied as string(ev.Type); here we want +// "catalog.projection_published" which is exactly that. We pass the +// underlying *amqp.Connection that rc.Connection() returns. +// +// Note: PublishEvent transiently opens + closes a channel per publish. +// At -tick ≈ 5s that's negligible, but if this CLI is ever pushed to +// high rates, swap to a single long-lived channel + Publish(). +func publishEvent(rc *rabbit.Conn, exchange string, ev event.Event) error { + return rabbit.PublishEvent(rc.Connection(), exchange, ev) +} + +// declareExchange opens a channel and issue-s a topic exchange declare so a +// fresh broker has the topology the harness's reader and the cart bridge +// expect. Idempotent. +func declareExchange(rc *rabbit.Conn, exchange string) error { + ch, err := rc.Channel() + if err != nil { + return fmt.Errorf("open channel: %w", err) + } + defer ch.Close() + if err := ch.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { + return fmt.Errorf("exchange declare: %w", err) + } + return nil +} + +// splitCSV trims and splits a comma-separated tenant list. +func splitCSV(s string) []string { + if s == "" { + return nil + } + out := make([]string, 0, 2) + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// cleanup runs the registered shutdown hooks before os.Exit so a mid-run +// failure does not leak the AMQP connection (or any other resource +// registered later). main() assigns a closure to cleanup that closes the +// rabbit.Conn; fail() invokes it before exiting. +// +// We can't rely solely on defer cleanup() because os.Exit skips defers; the +// fail path is reachable from several sites before a defer would normally +// fire, plus the connection may be the only system resource the binary +// opened. Same pattern as platform/cmd/catalog-emit's cleanup hook. +var cleanup = func() {} + +// fail prints a FATAL line, runs registered cleanup, and exits with code 1. +func fail(format string, a ...any) { + fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", a...) + cleanup() + os.Exit(1) +} diff --git a/cmd/internal/catalogtest/items.go b/cmd/internal/catalogtest/items.go new file mode 100644 index 0000000..5751588 --- /dev/null +++ b/cmd/internal/catalogtest/items.go @@ -0,0 +1,138 @@ +// Package catalogtest holds shared helpers for the platform catalog test +// harnesses — cmd/catalog-emit (consumer harness) and cmd/event-emit-publisher +// (test publisher) both use these to seed a source-item set with the same +// shape conventions. Anything in this package is a test-only utility; do +// not import from production code. +// +// Scope: this package is parked under platform/cmd/internal/ on purpose. +// Go's internal-package rule restricts imports to packages within the +// parent directory's subtree, so only platform/cmd/* binaries can import +// it. That keeps the test-helper API out of the public platform surface +// (e.g. platform/event, platform/rabbit must not import catalogtest — +// they're cross-module seams, not test scaffolding). +package catalogtest + +import ( + "encoding/json" + "fmt" + "math/rand" + "os" + "strconv" + + "git.k6n.net/mats/platform/catalog" +) + +// LoadOrSynthesize returns one source-batch per tenant name. If itemsPath +// is non-empty, it parses a {"tenant":[...]}-keyed JSON map and returns +// it unchanged. Otherwise it generates -n synthetic items per tenant +// using the tenant's registered Schema's field names, with skuPrefix +// prepended to each SKU. +// +// schemaFor is supplied so callers must already have a Registry built +// (production code paths usually have a Registry at composition root; +// publishing this helper to test code keeps the hot path clean). +// +// skuPrefix is a single-character prefix that lets log lines / probe +// output attribute synthetic items to the binary that produced them +// ("B" for catalog-emit, "P" for publisher). Substituting per-binary +// retains recognisability in mixed test runs. +// +// Determinism: synth items are content-version-identical between runs at +// the same (schema, seed, i). UpdatedAt is Unix-ms seeded from `i`. +func LoadOrSynthesize(itemsPath string, tenants []string, n int, seed int64, reg *catalog.Registry, skuPrefix string) (map[string][]map[string]any, error) { + if itemsPath != "" { + data, err := os.ReadFile(itemsPath) + if err != nil { + return nil, err + } + var raw map[string][]map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse items: %w", err) + } + return raw, nil + } + rng := rand.New(rand.NewSource(seed)) + out := make(map[string][]map[string]any, len(tenants)) + for _, t := range tenants { + s := reg.SchemaFor(t) + items := make([]map[string]any, n) + for i := 0; i < n; i++ { + items[i] = SynthItem(s, rng, i, skuPrefix) + } + out[t] = items + } + return out, nil +} + +// SynthItem builds one source-map whose key names match the supplied +// schema's field names. catalog.Apply on (s, item) succeeds for any +// schema that uses the same field-name conventions as DefaultSchema() / +// BygglagretSchema() / a Registry-supplied override. +// +// skuPrefix is prepended to each SKU (e.g. "B" or "P"); reasonable +// titles and prices are derived from a stable per-i seed plus the rng. +// Title text is intentionally uniform across binaries so log lines +// diff-cleanly between catalog-emit and event-emit-publisher runs. +// +// UpdatedAt is deterministic per i (Unix-ms, stable across runs) so the +// synthesised projection is content-version-identical between runs at +// the same (schema, seed, i). +func SynthItem(s catalog.Schema, rng *rand.Rand, i int, skuPrefix string) map[string]any { + sku := fmt.Sprintf("%s%07d", skuPrefix, i) + m := map[string]any{} + if s.SKUField != "" { + m[s.SKUField] = sku + } + if s.TitleField != "" { + m[s.TitleField] = fmt.Sprintf("Catalog Item %07d", i) + } + if s.PriceField != "" { + m[s.PriceField] = int64(99 + rng.Intn(9000)) + } + if s.CurrencyField != "" { + m[s.CurrencyField] = "SEK" + } + if s.TaxClassField != "" { + m[s.TaxClassField] = "standard" + } + if s.InventoryTrackedF != "" { + m[s.InventoryTrackedF] = rng.Intn(2) == 0 + } + if s.DropShipF != "" { + m[s.DropShipF] = rng.Intn(8) == 0 + } + if s.CategoryField != "" { + m[s.CategoryField] = fmt.Sprintf("c-%d", rng.Intn(50)) + } + if s.BrandField != "" { + m[s.BrandField] = "Acme" + } + if s.UpdatedAtField != "" { + m[s.UpdatedAtField] = int64(1700000000000 + i) + } + return m +} + +// StringField returns the string value of a source key, accepting +// strings or integral-number source values (mirrors writeradmin's +// helper of the same name). Used by delete-marker detection where +// the body field may be either JSON-string "999" or JSON-number 999. +// +// Empty key returns ""; unknown type returns "". Caller decides +// what "" means (in writeradmin / publisher buildBatch it means +// "skip this delete — no SKU to delete on"). +func StringField(src map[string]any, key string) string { + if key == "" { + return "" + } + switch v := src[key].(type) { + case string: + return v + case float64: + if v == float64(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + } + return "" +} diff --git a/event/event.go b/event/event.go new file mode 100644 index 0000000..2cc4d1f --- /dev/null +++ b/event/event.go @@ -0,0 +1,95 @@ +// Package event is the platform's shared event vocabulary: one envelope shape +// (Event) and a small set of Type constants for the event meanings that +// genuinely cross module boundaries. It imports only the stdlib — never an app +// module, never a transport library — so every module can depend on it. +// +// Discipline: a Type constant earns its place only when a real producer emits it +// AND it is meaningful to another module. We do not pre-declare aspirational +// types. See docs/platform-event-messaging-sketch.md. +package event + +import ( + "context" + "encoding/json" + "time" +) + +// Type is the semantic event kind — the cross-module contract. A module may use +// its own private kinds internally; only meanings that cross a boundary belong +// here. +type Type string + +const ( + // Editorial (graph-cms). Emitted by the CMS outbound bridge on content-graph + // mutations. In-repo consumers are TBD (external generators today); this is + // the first envelope producer, so the constants exist where they are emitted. + ContentCreated Type = "content.created" + ContentUpdated Type = "content.updated" + ContentDeleted Type = "content.deleted" + + // Catalog (finder). These meanings already cross modules today — finder + // writer → reader and → backoffice (MountFinderListener) — but still travel + // as finder's legacy ChangeTopic strings on the wire. The constants document + // the shared vocabulary; the wire migration onto the envelope is a later step. + CatalogItemChanged Type = "catalog.item_changed" // legacy: finder "item_added" + CatalogSnapshotReady Type = "catalog.snapshot_ready" // legacy: finder "snapshot_ready" + CatalogSettingsChanged Type = "catalog.settings_changed" // legacy: finder "settings_change" + CatalogFacetChanged Type = "catalog.facet_changed" // legacy: finder "facet_change" + CatalogAccessoryChanged Type = "catalog.accessory_changed" // legacy: finder "accessory_change" + // CatalogProjectionPublished carries the commerce-facing catalog Projection + // (platform/catalog) — producer maps raw items → Projection once, consumers + // (cart / checkout / order / UCP) read the typed shape. Distinct from + // CatalogItemChanged (raw bodies, consumed by inventory/backoffice): a + // product index emits BOTH; a non-product index emits neither projection. + // Payload is a []catalog.ProjectionUpdate batch (upserts + deletes per SKU). + CatalogProjectionPublished Type = "catalog.projection_published" + CatalogSortOverride Type = "catalog.sort_override" // finder-internal: per-session/group/popularity overrides + CatalogFieldSortOverride Type = "catalog.field_sort_override" // finder-internal: live field ordering overrides + + // Inventory (go-redis-inventory core, emitted by the cart-inventory service). + // A coarse stock-level crossing (none/low/high) for one SKU+location — + // never a quantity. Exact stock is a synchronous query against the inventory + // service; only level crossings cross a module boundary. Payload is an + // inventory.LevelChanged (platform/inventory). First consumer: finder stock + // overlay (display hints). See docs/inventory-shape.md. + InventoryLevelChanged Type = "inventory.level_changed" + + // Commerce (order). Emitted by the order service saga after an order is + // placed-and-paid (a durable outbox event). The authoritative "a sale + // happened" fact — multiple modules react independently (inventory commits + // stock, fulfillment allocates) without checkout coupling to any of them. + // Payload is an order.Created (platform/order). See docs/inventory-shape.md + // and docs/fulfillment-stance.md. + OrderCreated Type = "order.created" + + // Analytics (tracking). Replaces the integer enum 0–6 that finder and + // slask-tracking use on the `tracking` topic today. Producer: slask-finder + // (pkg/tracking/rabbit-tracking.go). Consumer: slask-tracking + // (pkg/events/rabbit-tracking.go). + TrackSession Type = "track.session" // 0 — session start + TrackSearch Type = "track.search" // 1 — search query + TrackClick Type = "track.click" // 2 — item click / product view + TrackCart Type = "track.cart" // 3,4 — add to / remove from cart + TrackImpression Type = "track.impression" // 5 — impression + TrackAction Type = "track.action" // 6 — custom action (wishlist, etc.) + TrackPageView Type = "track.page_view" + TrackPageEngaged Type = "track.page_engaged" + TrackPageAction Type = "track.page_action" +) + +// Event is the one envelope. Payload is per-Type, agreed between producer and +// consumer (json.RawMessage, not a god-struct). A nil Payload means "the subject +// changed; fetch the latest state." +type Event struct { + ID string `json:"id"` // unique; enables idempotent/at-least-once dedupe + Type Type `json:"type"` // the vocabulary + Source string `json:"source"` // module identifier ("graph-cms", "finder", ...) + Subject string `json:"subject"` // the resource that changed (node id, sku, ...) + Payload json.RawMessage `json:"payload,omitempty"` // per-Type body, or nil + Time time.Time `json:"time"` + Meta map[string]string `json:"meta,omitempty"` // trace id, causation, country, dataType, ... +} + +// Handler consumes an event. Returning an error means "not handled" — durable +// transports may redeliver; fire-and-forget transports just log. +type Handler func(ctx context.Context, e Event) error diff --git a/go.mod b/go.mod index 4c9529a..ee6c8b9 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module git.k6n.net/mats/platform go 1.26.2 + +require ( + github.com/rabbitmq/amqp091-go v1.11.0 + golang.org/x/sys v0.46.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ffa9cde --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= +github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/inventory/inventory.go b/inventory/inventory.go new file mode 100644 index 0000000..7893276 --- /dev/null +++ b/inventory/inventory.go @@ -0,0 +1,66 @@ +// Package inventory is the platform's shared inventory seam: the cross-module +// vocabulary for stock *levels*. It deliberately carries no quantities — exact +// stock is queried synchronously from the inventory service at decision points +// (PDP, add-to-cart, checkout reserve), and only coarse level crossings travel +// over the bus. Putting exact quantity on the bus spams it and ships a number +// that is stale the moment it is published; a level crossing is the only +// inventory signal other modules receive. See docs/inventory-shape.md. +// +// Like platform/catalog, this package holds only the wire value types plus the +// single classification function, so the producer and every consumer agree on +// the cutoffs. It imports only the stdlib — never an app module, never a +// transport library. +package inventory + +import "encoding/json" + +// Level is the coarse, bus-safe stock state. Consumers map a Level to a display +// hint / availability flag; they never see (or need) the exact quantity. +type Level string + +const ( + LevelNone Level = "none" // nothing available to sell + LevelLow Level = "low" // at or below the low watermark, but still > 0 + LevelHigh Level = "high" // comfortably above the low watermark +) + +// LevelFor classifies an exact on-hand quantity into a Level given the low +// watermark. It is the single source of the cutoffs: the producer computes the +// emitted level with it, and any consumer that needs to reclassify a quantity +// (e.g. after a synchronous exact query) uses the same function so the two can +// never disagree. qty <= 0 → none; 0 < qty <= low → low; qty > low → high. +func LevelFor(qty, lowWatermark int64) Level { + switch { + case qty <= 0: + return LevelNone + case qty <= lowWatermark: + return LevelLow + default: + return LevelHigh + } +} + +// InStock reports whether the level means the SKU can be sold (any level other +// than none) — a convenience for consumers that only care about the boolean. +func (l Level) InStock() bool { return l != LevelNone } + +// LevelChanged is the payload of an event.InventoryLevelChanged envelope: the +// SKU + location whose level crossed a threshold, and the new level — never a +// quantity. The envelope's Subject duplicates SKU; Meta["country"] carries the +// tenant/country for consumer routing (matching the catalog wire convention). +type LevelChanged struct { + SKU string `json:"sku"` + Location string `json:"location"` + Level Level `json:"level"` +} + +// Encode serialises the payload for event.Event.Payload. Kept here so producer +// and consumers share the exact wire shape; any future renaming lands here. +func (c LevelChanged) Encode() ([]byte, error) { return json.Marshal(c) } + +// Decode parses an InventoryLevelChanged payload — the consumer side of Encode. +func Decode(raw []byte) (LevelChanged, error) { + var c LevelChanged + err := json.Unmarshal(raw, &c) + return c, err +} diff --git a/inventory/inventory_test.go b/inventory/inventory_test.go new file mode 100644 index 0000000..cea82b2 --- /dev/null +++ b/inventory/inventory_test.go @@ -0,0 +1,47 @@ +package inventory + +import "testing" + +func TestLevelFor(t *testing.T) { + const low = int64(5) + cases := []struct { + qty int64 + want Level + }{ + {-3, LevelNone}, + {0, LevelNone}, + {1, LevelLow}, + {5, LevelLow}, // at the watermark is still low + {6, LevelHigh}, + {1000, LevelHigh}, + } + for _, c := range cases { + if got := LevelFor(c.qty, low); got != c.want { + t.Errorf("LevelFor(%d, %d) = %q, want %q", c.qty, low, got, c.want) + } + } +} + +func TestLevelInStock(t *testing.T) { + if LevelNone.InStock() { + t.Error("none should not be in stock") + } + if !LevelLow.InStock() || !LevelHigh.InStock() { + t.Error("low and high should be in stock") + } +} + +func TestLevelChangedRoundTrip(t *testing.T) { + in := LevelChanged{SKU: "ABC-1", Location: "se", Level: LevelLow} + raw, err := in.Encode() + if err != nil { + t.Fatalf("encode: %v", err) + } + out, err := Decode(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + if out != in { + t.Errorf("round-trip mismatch: got %+v, want %+v", out, in) + } +} diff --git a/mcp/jsonrpc.go b/mcp/jsonrpc.go new file mode 100644 index 0000000..037dfaf --- /dev/null +++ b/mcp/jsonrpc.go @@ -0,0 +1,47 @@ +package mcp + +import "encoding/json" + +// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are +// requests with no id and must not receive a response. + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// isNotification reports whether the message is a notification (no id), which +// per JSON-RPC must not receive a response. +func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 } + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +// JSON-RPC standard error codes. +const ( + codeParseError = -32700 + codeInvalidRequest = -32600 + codeMethodNotFound = -32601 + codeInvalidParams = -32602 + codeInternalError = -32603 +) + +func result(id json.RawMessage, v any) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v} +} + +func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse { + return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}} +} diff --git a/mcp/schema.go b/mcp/schema.go new file mode 100644 index 0000000..4d18692 --- /dev/null +++ b/mcp/schema.go @@ -0,0 +1,55 @@ +package mcp + +import "encoding/json" + +// Tiny JSON-Schema builders for tool input schemas. They keep tool definitions +// readable without a schema dependency. + +// Props is a set of named property schemas for Object. +type Props map[string]json.RawMessage + +// Object builds an object JSON Schema from named property schemas and the list +// of required keys. required may be nil. +func Object(p Props, required []string) json.RawMessage { + schema := map[string]any{"type": "object"} + if len(p) > 0 { + schema["properties"] = p + } else { + schema["properties"] = map[string]any{} + } + if len(required) > 0 { + schema["required"] = required + } + b, _ := json.Marshal(schema) + return b +} + +// Scalar builds a single-typed schema with a description (e.g. Scalar("integer", …)). +func Scalar(typ, desc string) json.RawMessage { + b, _ := json.Marshal(map[string]string{"type": typ, "description": desc}) + return b +} + +func String(desc string) json.RawMessage { return Scalar("string", desc) } +func Integer(desc string) json.RawMessage { return Scalar("integer", desc) } +func Number(desc string) json.RawMessage { return Scalar("number", desc) } +func Bool(desc string) json.RawMessage { return Scalar("boolean", desc) } +func ObjectValue(desc string) json.RawMessage { + return Scalar("object", desc) +} + +// StringArray builds an array-of-strings schema. +func StringArray(desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{ + "type": "array", "description": desc, "items": map[string]string{"type": "string"}, + }) + return b +} + +// ObjectArray builds an array-of-objects schema. +func ObjectArray(desc string) json.RawMessage { + b, _ := json.Marshal(map[string]any{ + "type": "array", "description": desc, "items": map[string]string{"type": "object"}, + }) + return b +} diff --git a/mcp/server.go b/mcp/server.go new file mode 100644 index 0000000..3d97cb7 --- /dev/null +++ b/mcp/server.go @@ -0,0 +1,181 @@ +// Package mcp is the shared Model Context Protocol server core: JSON-RPC 2.0 +// over streamable HTTP, tool registration, and the tools/list + tools/call +// dispatch. It is transport/protocol plumbing only — the tools themselves +// (their domain logic) stay in each module. A module builds its []Tool and calls +// NewServer; nothing here knows about carts, content, or orders. +// +// See docs/platform-uniforming-plan.md §3b and platform-event-messaging-sketch.md. +package mcp + +import ( + "context" + "encoding/json" + "io" + "net/http" +) + +// defaultProtocolVersion is advertised when Info.ProtocolVersion is empty. +const defaultProtocolVersion = "2025-06-18" + +const maxBodyBytes = 4 << 20 // 4 MiB request cap, matching the prior per-module servers. + +// Info identifies the server in the MCP initialize handshake. +type Info struct { + Name string + Version string + ProtocolVersion string // optional; defaults to defaultProtocolVersion +} + +// Server is the MCP edge over a set of tools. Build it with NewServer and mount +// Handler() (e.g. at /mcp). The tool slice is read on every request, so AddTool +// is safe after Handler() is in use. +type Server struct { + info Info + tools []Tool +} + +// NewServer builds an MCP server advertising the given tools. +func NewServer(info Info, tools ...Tool) *Server { + if info.ProtocolVersion == "" { + info.ProtocolVersion = defaultProtocolVersion + } + return &Server{info: info, tools: tools} +} + +// AddTool appends a tool so it is advertised via tools/list and dispatched via +// tools/call alongside the rest. +func (s *Server) AddTool(t Tool) { s.tools = append(s.tools, t) } + +// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp). +func (s *Server) Handler() http.Handler { return http.HandlerFunc(s.serveHTTP) } + +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) + if err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "read error")) + return + } + ctx := r.Context() + if isBatch(body) { + var reqs []rpcRequest + if err := json.Unmarshal(body, &reqs); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + var out []*rpcResponse + for i := range reqs { + if resp := s.dispatch(ctx, &reqs[i]); resp != nil { + out = append(out, resp) + } + } + if len(out) == 0 { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, out) + return + } + var req rpcRequest + if err := json.Unmarshal(body, &req); err != nil { + writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON")) + return + } + resp := s.dispatch(ctx, &req) + if resp == nil { + w.WriteHeader(http.StatusAccepted) + return + } + writeRPC(w, resp) +} + +func (s *Server) dispatch(ctx context.Context, req *rpcRequest) *rpcResponse { + if req.JSONRPC != "2.0" { + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"") + } + switch req.Method { + case "initialize": + return result(req.ID, s.initialize(req.Params)) + case "ping": + return result(req.ID, struct{}{}) + case "tools/list": + return result(req.ID, map[string]any{"tools": s.tools}) + case "tools/call": + return s.callTool(ctx, req) + case "notifications/initialized", "notifications/cancelled": + return nil // notifications: acknowledged by the transport, no reply + default: + if req.isNotification() { + return nil + } + return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method) + } +} + +func (s *Server) initialize(params json.RawMessage) map[string]any { + pv := s.info.ProtocolVersion + if len(params) > 0 { + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" { + pv = p.ProtocolVersion + } + } + return map[string]any{ + "protocolVersion": pv, + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": s.info.Name, "version": s.info.Version}, + } +} + +func (s *Server) callTool(ctx context.Context, req *rpcRequest) *rpcResponse { + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + return errorResponse(req.ID, codeInvalidParams, "invalid params") + } + // Strip (and log) the UCP-Agent meta sibling before handing args to the tool, + // so it never leaks into the tool's argument namespace. No-op when absent. + args := extractUCPAgentMeta(p.Arguments) + + for i := range s.tools { + if s.tools[i].Name == p.Name { + out, err := s.tools[i].Invoke(ctx, args) + if err != nil { + return result(req.ID, ErrorResult(err)) + } + return result(req.ID, Text(out)) + } + } + return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name) +} + +func isBatch(body []byte) bool { + for _, b := range body { + switch b { + case ' ', '\t', '\r', '\n': + continue + case '[': + return true + default: + return false + } + } + return false +} + +func writeRPC(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/mcp/tool.go b/mcp/tool.go new file mode 100644 index 0000000..bb52e75 --- /dev/null +++ b/mcp/tool.go @@ -0,0 +1,51 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" +) + +// Tool is one MCP tool: its advertised schema plus the Invoke func that parses +// arguments, does the work, and returns a JSON-encodable payload. Invoke's error +// is surfaced to the client as a tool error (isError: true), not a protocol +// error. The context carries request scope (deadline, cancellation, trace span); +// it flows from the HTTP request through to Invoke. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` + Invoke func(ctx context.Context, args json.RawMessage) (any, error) `json:"-"` +} + +// Text wraps a payload as a successful tools/call result: one JSON text block. +func Text(v any) any { + b, err := json.Marshal(v) + if err != nil { + return ErrorResult(err) + } + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(b)}}, + } +} + +// ErrorResult wraps an operational error as an isError tools/call result (a tool +// failure the model can read, not a JSON-RPC protocol error). +func ErrorResult(err error) any { + return map[string]any{ + "isError": true, + "content": []map[string]any{{"type": "text", "text": err.Error()}}, + } +} + +// Decode unmarshals tool arguments into v, treating empty/null args as a no-op +// (zero value). Returns a wrapped error suitable for returning from Invoke. +func Decode(args json.RawMessage, v any) error { + if len(args) == 0 || string(args) == "null" { + return nil + } + if err := json.Unmarshal(args, v); err != nil { + return fmt.Errorf("invalid arguments: %w", err) + } + return nil +} diff --git a/mcp/ucp.go b/mcp/ucp.go new file mode 100644 index 0000000..9778b41 --- /dev/null +++ b/mcp/ucp.go @@ -0,0 +1,40 @@ +package mcp + +import ( + "encoding/json" + "log" +) + +// extractUCPAgentMeta checks whether the JSON arguments carry a "meta" key with +// a nested "ucp-agent.profile" (the UCP-Agent profile advertisement, a sibling +// of the real arguments per the UCP spec). If present it logs the profile for +// observability and strips "meta" so it does not leak into the tool's argument +// namespace. When absent it returns the arguments unchanged — a no-op for tools +// that don't speak UCP. +func extractUCPAgentMeta(arguments json.RawMessage) json.RawMessage { + if len(arguments) == 0 { + return arguments + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(arguments, &obj); err != nil { + return arguments + } + metaRaw, hasMeta := obj["meta"] + if !hasMeta { + return arguments + } + var meta struct { + UCPAgent *struct { + Profile string `json:"profile"` + } `json:"ucp-agent"` + } + if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" { + log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile) + } + delete(obj, "meta") + cleaned, err := json.Marshal(obj) + if err != nil { + return arguments + } + return cleaned +} diff --git a/money/money.go b/money/money.go new file mode 100644 index 0000000..fb1c157 --- /dev/null +++ b/money/money.go @@ -0,0 +1,143 @@ +// Package money is the platform's shared monetary value type. One rule, enforced +// by the type system: every amount in the system is an integer count of minor +// currency units (öre / cents) — never a float, never "kronor as a decimal". +// +// Why a named type and not bare int64? Two int64s can be a price and a quantity; +// the compiler can't tell them apart. money.Cents makes "this is money" explicit +// and stops a quantity being added to a price by accident. The underlying type +// is int64, so arithmetic between Cents values is ordinary integer math and JSON +// encodes identically to the int64 wire shape that already exists. +// +// Floating point is permitted in exactly one situation: ingesting a price from +// an external source that already shipped a float (a facet value, a third-party +// product feed). That conversion is lossy by definition — the float was imprecise +// before we saw it — so it is isolated to FromFloatMajor, named loudly, and used +// only at module edges. Everywhere else, parse from the original decimal STRING +// with ParseCents (exact) or stay in Cents. +// +// Minor-unit exponent: this package assumes 2 decimal places (1 unit = 1/100), +// which covers every currency in this codebase (SEK/NOK/DKK/EUR/USD). JPY-style +// zero-decimal currencies would need a per-currency exponent; deferred until a +// real consumer needs it (see doc rule: lift only what a consumer needs). +package money + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +// Cents is an integer count of minor currency units (öre / cents). Positive, +// negative (refunds / discounts), and zero are all valid. +type Cents int64 + +// minorPerMajor is the number of minor units in one major unit (2 decimals). +const minorPerMajor = 100 + +// ErrParse is the sentinel wrapped by ParseCents failures, for errors.Is. +var ErrParse = errors.New("money: parse") + +// ParseCents converts a decimal MAJOR-unit string ("149.95", "-12", "0.05") +// into Cents using integer arithmetic only — no strconv.ParseFloat, no float +// multiply, so "149.95" → 14995 exactly with no representation error. +// +// Accepted: optional leading sign, digits, an optional single '.' with at most +// two fractional digits. Rejected (returns ErrParse): empty, multiple dots, +// more than two fractional digits (silent truncation of a third decimal is +// exactly the kind of money bug this package exists to prevent), or any +// non-digit byte. A bare "." sides ("149." / ".95") are accepted as 14900 / 5. +func ParseCents(s string) (Cents, error) { + in := strings.TrimSpace(s) + if in == "" { + return 0, fmt.Errorf("%w: empty string", ErrParse) + } + + neg := false + switch in[0] { + case '-': + neg = true + in = in[1:] + case '+': + in = in[1:] + } + if in == "" { + return 0, fmt.Errorf("%w: sign with no digits in %q", ErrParse, s) + } + + intStr, fracStr, hasDot := strings.Cut(in, ".") + if hasDot { + if strings.IndexByte(fracStr, '.') >= 0 { + return 0, fmt.Errorf("%w: multiple decimal points in %q", ErrParse, s) + } + if len(fracStr) > 2 { + return 0, fmt.Errorf("%w: %q has more than 2 decimal places (minor unit is 1/%d)", ErrParse, s, minorPerMajor) + } + } + if intStr == "" { + intStr = "0" + } + // Pad the fractional part to exactly 2 digits: "5" → "50", "" → "00". + switch len(fracStr) { + case 0: + fracStr = "00" + case 1: + fracStr += "0" + } + + major, err := strconv.ParseInt(intStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("%w: integer part of %q: %v", ErrParse, s, err) + } + frac, err := strconv.ParseInt(fracStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("%w: fractional part of %q: %v", ErrParse, s, err) + } + + // Overflow guard: major * 100 + frac must fit int64. + if major > (math.MaxInt64-frac)/minorPerMajor { + return 0, fmt.Errorf("%w: %q overflows int64 minor units", ErrParse, s) + } + c := Cents(major*minorPerMajor + frac) + if neg { + c = -c + } + return c, nil +} + +// FromFloatMajor converts a MAJOR-unit float (149.95) to Cents, rounding +// half-away-from-zero. THIS IS THE ONLY LOSSY ENTRY POINT and exists solely for +// edges where an upstream already handed us a float64 (a finder facet value, a +// third-party feed). Never use it on a value you have as a string — ParseCents +// is exact. Never use it in a compute path — stay in Cents. +func FromFloatMajor(f float64) Cents { + scaled := f * minorPerMajor + if scaled >= 0 { + return Cents(int64(scaled + 0.5)) + } + return Cents(int64(scaled - 0.5)) +} + +// String formats Cents as a major-unit decimal with two places: 14995 → +// "149.95", -5 → "-0.05". Integer math only. +func (c Cents) String() string { + n := int64(c) + sign := "" + if n < 0 { + sign = "-" + n = -n + } + return fmt.Sprintf("%s%d.%02d", sign, n/minorPerMajor, n%minorPerMajor) +} + +// Int64 returns the raw minor-unit count, for the few low-level call sites +// (proto, legacy arithmetic helpers) that still take int64. Prefer staying in +// Cents; this is the explicit escape hatch. +func (c Cents) Int64() int64 { return int64(c) } + +// Mul scales an amount by an integer quantity — a price times a line quantity +// is still money. Kept as a method so callers don't have to write the +// Cents(qty) conversion that bare `c * Cents(qty)` would need (and which reads +// like cents², a nonsense unit). +func (c Cents) Mul(qty int64) Cents { return c * Cents(qty) } diff --git a/money/money_test.go b/money/money_test.go new file mode 100644 index 0000000..609c0f1 --- /dev/null +++ b/money/money_test.go @@ -0,0 +1,129 @@ +package money + +import ( + "errors" + "testing" +) + +func TestParseCentsExact(t *testing.T) { + cases := []struct { + in string + want Cents + }{ + {"149.95", 14995}, + {"149.9", 14990}, + {"149", 14900}, + {"149.", 14900}, + {".95", 95}, + {"0.05", 5}, + {"0", 0}, + {"0.00", 0}, + {"-12.34", -1234}, + {"-0.05", -5}, + {"+7.50", 750}, + {" 10.10 ", 1010}, + {"1000000.00", 100000000}, + // The classic float-representation trap: 0.1+0.2 etc. Parsed from the + // string these are exact; ParseFloat would have drifted. + {"0.10", 10}, + {"0.20", 20}, + {"0.30", 30}, + } + for _, c := range cases { + got, err := ParseCents(c.in) + if err != nil { + t.Errorf("ParseCents(%q) unexpected error: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("ParseCents(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestParseCentsRejects(t *testing.T) { + bad := []string{ + "", + " ", + "-", + "+", + "abc", + "12.3.4", // multiple dots + "149.955", // 3 decimal places — would silently truncate + "1.234", // 3 decimal places + "12,34", // comma not a decimal separator here + "1e3", // no scientific notation; integer-only parse + "12 34", // internal space + "$10.00", // currency symbol + } + for _, s := range bad { + if _, err := ParseCents(s); err == nil { + t.Errorf("ParseCents(%q) = nil error, want rejection", s) + } else if !errors.Is(err, ErrParse) { + t.Errorf("ParseCents(%q) error %v not wrapping ErrParse", s, err) + } + } +} + +func TestStringRoundTrip(t *testing.T) { + cases := []struct { + c Cents + want string + }{ + {14995, "149.95"}, + {14900, "149.00"}, + {5, "0.05"}, + {50, "0.50"}, + {0, "0.00"}, + {-1234, "-12.34"}, + {-5, "-0.05"}, + {100000000, "1000000.00"}, + } + for _, tc := range cases { + if got := tc.c.String(); got != tc.want { + t.Errorf("Cents(%d).String() = %q, want %q", tc.c, got, tc.want) + } + // Round-trip: String -> ParseCents -> same Cents. + back, err := ParseCents(tc.c.String()) + if err != nil { + t.Errorf("round-trip ParseCents(%q): %v", tc.c.String(), err) + continue + } + if back != tc.c { + t.Errorf("round-trip Cents(%d) -> %q -> %d", tc.c, tc.c.String(), back) + } + } +} + +func TestFromFloatMajor(t *testing.T) { + cases := []struct { + f float64 + want Cents + }{ + {149.95, 14995}, + {149.0, 14900}, + {0.05, 5}, + {0.005, 1}, // half-away-from-zero rounds up at the boundary + {0.004, 0}, + {-12.34, -1234}, + {-0.005, -1}, + } + for _, c := range cases { + if got := FromFloatMajor(c.f); got != c.want { + t.Errorf("FromFloatMajor(%v) = %d, want %d", c.f, got, c.want) + } + } +} + +func TestArithmeticStaysInteger(t *testing.T) { + // Cents arithmetic is plain integer math — associativity / exactness that + // float prices famously lack. + unit := Cents(1099) // 10.99 + var total Cents + for i := 0; i < 3; i++ { + total += unit + } + if total != 3297 { + t.Errorf("3 * 10.99 = %s, want 32.97", total) + } +} diff --git a/order/order.go b/order/order.go new file mode 100644 index 0000000..3f445fe --- /dev/null +++ b/order/order.go @@ -0,0 +1,53 @@ +// Package order is the platform's shared order seam: the exported wire shape of +// the `order.created` domain event (B4.1). It is the contract between the order +// service (producer) and every reactor — inventory commit, fulfillment +// allocation, loyalty, ERP sync — none of which the order service knows about. +// +// Like platform/catalog and platform/inventory, it holds only the wire value +// types + encode/decode, and imports only the stdlib. +// +// `order.created` is a FACT, not a request: by the time it is emitted the order +// is placed and paid. Reactors must be idempotent (the transport is +// at-least-once) and must not assume they can refuse it — the sale already +// happened. Oversell on commit is reconciled downstream (backorder), not +// prevented here. +package order + +import "encoding/json" + +// Line is one ordered line item, reduced to what reactors need: the SKU, how +// many units sold, and the inventory location to commit against. Pricing/tax +// live in the order grain, not on this seam. +type Line struct { + SKU string `json:"sku"` + Quantity int `json:"quantity"` + // Location is the inventory location / store id to decrement. Empty means + // "use the order's Country" (central stock) — see Created.Country. + Location string `json:"location,omitempty"` +} + +// Created is the payload of an event.OrderCreated envelope. +// +// Country is the default inventory location for the commit (central stock). A +// Line may override it with its own Location (store id); when a line's Location +// is empty the commit falls back to Country. See docs/inventory-shape.md. +type Created struct { + OrderID string `json:"order_id"` + OrderReference string `json:"order_reference,omitempty"` + CartID string `json:"cart_id,omitempty"` + Country string `json:"country"` + Currency string `json:"currency,omitempty"` + CustomerEmail string `json:"customer_email,omitempty"` + Lines []Line `json:"lines"` + PlacedAtMs int64 `json:"placed_at_ms,omitempty"` +} + +// Encode serialises the payload for event.Event.Payload. +func (c Created) Encode() ([]byte, error) { return json.Marshal(c) } + +// Decode parses an order.created payload — the consumer side of Encode. +func Decode(raw []byte) (Created, error) { + var c Created + err := json.Unmarshal(raw, &c) + return c, err +} diff --git a/order/order_test.go b/order/order_test.go new file mode 100644 index 0000000..8a0ebf1 --- /dev/null +++ b/order/order_test.go @@ -0,0 +1,30 @@ +package order + +import "testing" + +func TestCreatedRoundTrip(t *testing.T) { + in := Created{ + OrderID: "ord-1", + OrderReference: "REF-1", + CartID: "cart-9", + Country: "se", + Currency: "SEK", + CustomerEmail: "a@b.c", + Lines: []Line{{SKU: "ABC", Quantity: 2, Location: "store-7"}, {SKU: "DEF", Quantity: 1}}, + PlacedAtMs: 123, + } + raw, err := in.Encode() + if err != nil { + t.Fatalf("encode: %v", err) + } + out, err := Decode(raw) + if err != nil { + t.Fatalf("decode: %v", err) + } + if out.OrderID != in.OrderID || out.Country != in.Country || len(out.Lines) != 2 { + t.Fatalf("round-trip mismatch: got %+v, want %+v", out, in) + } + if out.Lines[0].SKU != "ABC" || out.Lines[0].Quantity != 2 || out.Lines[0].Location != "store-7" { + t.Errorf("line[0] mismatch: %+v", out.Lines[0]) + } +} diff --git a/rabbit/conn.go b/rabbit/conn.go new file mode 100644 index 0000000..84889f7 --- /dev/null +++ b/rabbit/conn.go @@ -0,0 +1,265 @@ +// Package rabbit holds the shared RabbitMQ transport mechanics — connection +// setup plus topic declare/publish/consume helpers — that every module used to +// hand-roll. It carries NO event vocabulary: topic and routing names are plain +// strings supplied by the caller, so each module keeps its own naming policy +// (finder's "_", CMS's "kind.dataType", etc.). See +// docs/platform-uniforming-plan.md Win 3. +package rabbit + +import ( + "log" + "sync" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// Default backoff bounds for the automatic reconnect loop. +const ( + backoffMin = 1 * time.Second + backoffMax = 30 * time.Second +) + +// Connect dials url with a consistent configuration, tagging the connection with +// name so it is identifiable in the RabbitMQ management UI and metrics. It +// returns a bare *amqp.Connection and does NOT reconnect on failure. +// +// Prefer Dial instead: it returns a *Conn wrapper that handles automatic +// reconnection and notifies consumers when a reconnect completes so they can +// re-declare their topology. +// +// This replaces the 14 inconsistent hand-rolled dial sites (some with +// connection Properties, some bare amqp.Dial with no name). +func Connect(url, name string) (*amqp.Connection, error) { + return dial(url, name) +} + +// dial is the shared dial implementation used by both Connect and Dial. +func dial(url, name string) (*amqp.Connection, error) { + props := amqp.NewConnectionProperties() + if name != "" { + props["connection_name"] = name + } + return amqp.DialConfig(url, amqp.Config{Properties: props}) +} + +// --------------------------------------------------------------------------- + +// Conn wraps *amqp.Connection with automatic reconnection. After Dial, it +// monitors NotifyClose and re-dials with exponential backoff (1s→30s). All +// exported methods are safe for concurrent use. +// +// Usage: +// +// conn, err := rabbit.Dial(url, "my-service") +// if err != nil { return err } +// defer conn.Close() +// +// // Register consumer setup: called immediately AND on each reconnect. +// conn.NotifyOnReconnect(func() { +// ch, err := conn.Channel() +// if err != nil { return } +// rabbit.ListenToTopic(ch, prefix, topic, handler) +// }) +type Conn struct { + url string + name string + + mu sync.Mutex + conn *amqp.Connection + done chan struct{} // closed by Close(); stops the reconnect loop + + onReconn chan struct{} // closed on each reconnect to wake up listeners + + notifyFns []func() // registered via NotifyOnReconnect + reconnectFns []func() // registered via NotifyReconnect +} + +// Dial connects to RabbitMQ and returns a *Conn that automatically reconnects +// on connection loss. The name is shown in the RabbitMQ management UI. +func Dial(url, name string) (*Conn, error) { + c := &Conn{ + url: url, + name: name, + done: make(chan struct{}), + onReconn: make(chan struct{}), + } + raw, err := dial(url, name) + if err != nil { + return nil, err + } + c.conn = raw + + // Fire initial connect signal so a consumer registered before this runs + // (e.g. via NotifyOnReconnect above) gets called. Since onReconn starts + // open, we close it here and replace it with a fresh one for reconnect. + close(c.onReconn) + c.onReconn = make(chan struct{}) + + go c.reconnectLoop() + return c, nil +} + +// Connection returns the current *amqp.Connection. Safe for concurrent use. +// Use this to pass the underlying connection to functions that expect +// *amqp.Connection, such as SendChange and the finder's messaging wrappers. +func (c *Conn) Connection() *amqp.Connection { + c.mu.Lock() + defer c.mu.Unlock() + return c.conn +} + +// Channel opens a channel on the current underlying connection. It is safe +// for concurrent use; the error path matches *amqp.Connection.Channel. +func (c *Conn) Channel() (*amqp.Channel, error) { + return c.Connection().Channel() +} + +// OnReconnect returns a channel that is closed each time a reconnect completes. +// Use range to re-run consumer setup: +// +// go func() { +// for range conn.OnReconnect() { +// ch, _ := conn.Channel() +// rabbit.ListenToTopic(ch, prefix, topic, handler) +// } +// }() +// +// The channel is NOT signalled on initial connect — only on reconnects. +// For a function that fires on both initial connect and reconnect, use +// NotifyOnReconnect. +func (c *Conn) OnReconnect() <-chan struct{} { + c.mu.Lock() + defer c.mu.Unlock() + return c.onReconn +} + +// NotifyOnReconnect registers a function that is called immediately (on the +// current goroutine) and again on each subsequent reconnect (on the reconnect +// loop goroutine). The function should be idempotent — it may be called while +// the old listeners are still shutting down from the previous connection drop. +// Multiple functions can be registered and are called in registration order. +func (c *Conn) NotifyOnReconnect(fn func()) { + // Call immediately with the current (live) connection. + fn() + + c.mu.Lock() + c.notifyFns = append(c.notifyFns, fn) + c.mu.Unlock() +} + +// NotifyReconnect registers a function that is called on each subsequent +// reconnect, but NOT immediately on registration. Use this when the caller +// needs to perform first-time setup separately and only wants the reconnect +// hook for re-declaring consumers or topology. +func (c *Conn) NotifyReconnect(fn func()) { + c.mu.Lock() + c.reconnectFns = append(c.reconnectFns, fn) + c.mu.Unlock() +} + +// Done returns a channel that is closed when the connection is permanently +// closed (Close was called). +func (c *Conn) Done() <-chan struct{} { + return c.done +} + +// Close permanently closes the connection and stops the reconnect loop. +// It is safe to call multiple times. +func (c *Conn) Close() error { + select { + case <-c.done: + return nil + default: + } + close(c.done) + + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +// reconnectLoop runs in a background goroutine. It waits for the connection +// to close (either normally or with an error), then re-dials with exponential +// backoff and notifies registered callbacks. +func (c *Conn) reconnectLoop() { + for { + // Wait for the connection to break (or Close to be called). + errCh := make(chan *amqp.Error) + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + conn.NotifyClose(errCh) + + select { + case <-c.done: + return + case err, ok := <-errCh: + if !ok || err == nil { + // Channel closed or clean shutdown (Close called on the underlying + // connection). Either way, stop reconnecting. + return + } + log.Printf("rabbit: connection %s lost: %v (reconnecting)", c.name, err) + } + + // Reconnect with exponential backoff. + c.reconnect() + } +} + +// reconnect attempts to re-dial until successful or Close is called. +func (c *Conn) reconnect() { + duration := backoffMin + for { + select { + case <-c.done: + return + default: + } + + newConn, err := dial(c.url, c.name) + if err == nil { + c.mu.Lock() + old := c.conn + c.conn = newConn + c.mu.Unlock() + if old != nil { + _ = old.Close() + } + + log.Printf("rabbit: connection %s re-established", c.name) + + // Notify consumers: close the current signal channel so blocked + // OnReconnect calls advance; replace with a fresh one. + c.mu.Lock() + close(c.onReconn) + c.onReconn = make(chan struct{}) + fns := append([]func(){}, c.notifyFns...) + fns = append(fns, c.reconnectFns...) + c.mu.Unlock() + + for _, fn := range fns { + fn() + } + return + } + + log.Printf("rabbit: reconnect for %s failed: %v (retry in %v)", c.name, err, duration) + + select { + case <-c.done: + return + case <-time.After(duration): + } + + duration *= 2 + if duration > backoffMax { + duration = backoffMax + } + } +} diff --git a/rabbit/conn_test.go b/rabbit/conn_test.go new file mode 100644 index 0000000..ae7191a --- /dev/null +++ b/rabbit/conn_test.go @@ -0,0 +1,258 @@ +package rabbit_test + +import ( + "os" + "sync" + "testing" + "time" + + "git.k6n.net/mats/platform/rabbit" +) + +// rabbitURL returns the RabbitMQ URL for integration tests, or empty if unset. +// Tests check this and skip when no broker is available. +func rabbitURL() string { + if u := os.Getenv("RABBIT_URL"); u != "" { + return u + } + if u := os.Getenv("RABBIT_HOST"); u != "" { + return "amqp://" + u + } + // Default local dev URL used by docker-compose. + return "amqp://guest:guest@localhost:5672" +} + +// skipWithoutRabbit skips the test if no RabbitMQ broker is reachable. +func skipWithoutRabbit(t *testing.T) string { + t.Helper() + url := rabbitURL() + // Quick probe: try connecting and immediately close. + conn, err := rabbit.Connect(url, "test-probe") + if err != nil { + t.Skipf("RabbitMQ not available at %s: %v", url, err) + } + _ = conn.Close() + return url +} + +func TestDial_ConnectAndClose(t *testing.T) { + url := skipWithoutRabbit(t) + + conn, err := rabbit.Dial(url, "test-conn") + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer conn.Close() + + // The underlying connection should be usable. + if c := conn.Connection(); c == nil { + t.Fatal("Connection() returned nil") + } + ch, err := conn.Channel() + if err != nil { + t.Fatalf("Channel() failed: %v", err) + } + if err := ch.Close(); err != nil { + t.Fatalf("channel close: %v", err) + } +} + +func TestDial_Done(t *testing.T) { + url := skipWithoutRabbit(t) + + conn, err := rabbit.Dial(url, "test-done") + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + + // Done should NOT be closed initially. + select { + case <-conn.Done(): + t.Fatal("Done channel closed before Close()") + default: + } + + // After Close, Done should be closed. + _ = conn.Close() + select { + case <-conn.Done(): + // expected + case <-time.After(time.Second): + t.Fatal("Done channel not closed after Close()") + } + + // Double close is safe. + _ = conn.Close() +} + +func TestDial_NotifyOnReconnect(t *testing.T) { + url := skipWithoutRabbit(t) + + conn, err := rabbit.Dial(url, "test-notify") + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer conn.Close() + + // NotifyOnReconnect should call the function immediately. + called := make(chan struct{}, 1) + conn.NotifyOnReconnect(func() { + called <- struct{}{} + }) + + select { + case <-called: + // expected — immediate call + case <-time.After(time.Second): + t.Fatal("NotifyOnReconnect was not called immediately") + } + + // Multiple registrations should each fire immediately. + var mu sync.Mutex + count := 0 + conn.NotifyOnReconnect(func() { + mu.Lock() + count++ + mu.Unlock() + }) + conn.NotifyOnReconnect(func() { + mu.Lock() + count++ + mu.Unlock() + }) + + // Wait a moment for the callbacks to run. + time.Sleep(50 * time.Millisecond) + mu.Lock() + if count != 2 { + t.Fatalf("expected 2 immediate NotifyOnReconnect calls, got %d", count) + } + mu.Unlock() +} + +func TestDial_NotifyReconnect_NotImmediate(t *testing.T) { + url := skipWithoutRabbit(t) + + conn, err := rabbit.Dial(url, "test-notify-reconnect") + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer conn.Close() + + called := make(chan struct{}, 1) + conn.NotifyReconnect(func() { + called <- struct{}{} + }) + + select { + case <-called: + t.Fatal("NotifyReconnect fired immediately") + case <-time.After(100 * time.Millisecond): + // expected + } +} + +func TestDial_OnReconnectChannel(t *testing.T) { + url := skipWithoutRabbit(t) + + conn, err := rabbit.Dial(url, "test-onreconn") + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer conn.Close() + + // OnReconnect returns an open channel immediately after Dial. + // It should NOT fire (only triggers on actual reconnects). + select { + case <-conn.OnReconnect(): + t.Log("OnReconnect fired immediately (race with initial connect — allowed)") + default: + } + + // Verify OnReconnect returns a non-nil channel. + ch := conn.OnReconnect() + if ch == nil { + t.Fatal("OnReconnect returned nil channel") + } +} + +func TestDial_ConcurrentUse(t *testing.T) { + url := skipWithoutRabbit(t) + + conn, err := rabbit.Dial(url, "test-concurrent") + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer conn.Close() + + // Channel() and Connection() should be safe from multiple goroutines. + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ch, err := conn.Channel() + if err != nil { + t.Logf("concurrent Channel(): %v", err) + return + } + _ = ch.Close() + }() + } + wg.Wait() +} + +// TestOnReconnect_RegisteredFnsFiresOnReconnect is a structural test: it +// verifies that the registered notification list is called on reconnect by +// checking that functions registered after initial connect are not called +// immediately (they only fire on the NEXT reconnect). Full reconnect testing +// requires controlling the broker — see the integration test harness. +func TestOnReconnect_NotImmediate(t *testing.T) { + url := skipWithoutRabbit(t) + + conn, err := rabbit.Dial(url, "test-reconn-struct") + if err != nil { + t.Fatalf("Dial failed: %v", err) + } + defer conn.Close() + + // Register a function with NotifyOnReconnect AFTER Dial. It fires + // immediately (that's the contract). + immediate := make(chan struct{}, 1) + conn.NotifyOnReconnect(func() { + immediate <- struct{}{} + }) + + select { + case <-immediate: + // expected + case <-time.After(time.Second): + t.Fatal("NotifyOnReconnect did not fire immediately") + } + + // OnReconnect should NOT have fired (only fires on actual reconnect). + select { + case <-conn.OnReconnect(): + t.Log("OnReconnect fired — race goroutine from initial connect") + default: + } +} + +func TestConnect_BackwardCompat(t *testing.T) { + url := skipWithoutRabbit(t) + + // Connect should still work and return a usable *amqp.Connection. + conn, err := rabbit.Connect(url, "test-compat") + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + defer conn.Close() + + ch, err := conn.Channel() + if err != nil { + t.Fatalf("Channel from Connect failed: %v", err) + } + _ = ch.Close() +} + + diff --git a/rabbit/topic.go b/rabbit/topic.go new file mode 100644 index 0000000..3b715f7 --- /dev/null +++ b/rabbit/topic.go @@ -0,0 +1,64 @@ +package rabbit + +import ( + "encoding/json" + "log" + "time" + + "git.k6n.net/mats/platform/event" + amqp "github.com/rabbitmq/amqp091-go" +) + +// PublishEvent marshals an event.Event and publishes it to exchange with +// routingKey = string(ev.Type). Opens and closes a channel per call. The +// exchange must already exist (declare it with ListenToPattern or call +// ExchangeDeclare explicitly before the first publish). +func PublishEvent(c *amqp.Connection, exchange string, ev event.Event) error { + body, err := json.Marshal(ev) + if err != nil { + return err + } + ch, err := c.Channel() + if err != nil { + return err + } + defer ch.Close() + return ch.Publish(exchange, string(ev.Type), true, false, amqp.Publishing{ + ContentType: "application/json", + Body: body, + DeliveryMode: amqp.Persistent, + Timestamp: time.Now(), + }) +} + +// ListenToPattern binds an exclusive queue with a routing-key pattern (wildcard) +// on a durable topic exchange, so one subscription receives every message +// matching the pattern. A handler error nacks (drops) that single message and +// keeps consuming. +func ListenToPattern(ch *amqp.Channel, exchange, pattern string, handler func(amqp.Delivery) error) error { + if err := ch.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { + return err + } + q, err := ch.QueueDeclare("", false, false, true, false, nil) + if err != nil { + return err + } + if err := ch.QueueBind(q.Name, pattern, exchange, false, nil); err != nil { + return err + } + msgs, err := ch.Consume(q.Name, "", false, false, false, false, nil) + if err != nil { + return err + } + go func() { + for d := range msgs { + if err := handler(d); err != nil { + log.Printf("Error processing pattern message: %v", err) + d.Nack(false, false) + } else { + d.Ack(false) + } + } + }() + return nil +} diff --git a/seeds/catalog/schemas.json b/seeds/catalog/schemas.json new file mode 100644 index 0000000..4298778 --- /dev/null +++ b/seeds/catalog/schemas.json @@ -0,0 +1,36 @@ +{ + "default": { + "sku_field": "sku", + "title_field": "title", + "price_field": "price_inc_vat", + "currency_field": "currency", + "tax_class_field": "tax_class", + "inventory_tracked_field": "inventory_tracked", + "drop_ship_field": "drop_ship", + "category_field": "category", + "brand_field": "brand", + "image_field": "image", + "updated_at_field": "updated_at", + "updated_at_source": 1, + "price_scale": 1, + "id_prefix": "sku:", + "default_tax_class": "standard" + }, + "bygglagret": { + "sku_field": "ArticleNumber", + "title_field": "Name", + "price_field": "PriceInclVAT", + "tax_class_field": "VATClass", + "inventory_tracked_field": "StockTracked", + "drop_ship_field": "DropshipFlag", + "category_field": "CategoryPath", + "brand_field": "Brand", + "image_field": "PrimaryImage", + "updated_at_field": "LastModifiedMs", + "updated_at_source": 1, + "price_scale": 100, + "id_prefix": "sku:", + "fixed_currency": "SEK", + "default_tax_class": "standard" + } +} diff --git a/tax/nordic.go b/tax/nordic.go new file mode 100644 index 0000000..14d584d --- /dev/null +++ b/tax/nordic.go @@ -0,0 +1,56 @@ +package tax + +// nordicStandardRates maps country code -> default standard VAT rate in basis +// points of a percent (2500 = 25%). +var nordicStandardRates = map[string]int{ + "SE": 2500, + "NO": 2500, + "DK": 2500, + "FI": 2550, // 25.5% as of 2024 — exact in basis points, no truncation +} + +// Nordic implements Provider for the Nordic countries (SE, NO, DK, FI). +// It encodes the standard statutory VAT rates (basis points): +// +// SE: 25 % (2500) +// NO: 25 % (2500) +// DK: 25 % (2500) +// FI: 25.5 % (2550) +// +// Rates are integer basis points, so fractional rates (FI 25.5) are represented +// exactly — no float, no truncation. +// +// Nordic does not make network calls — all logic is local math. An external +// provider (Avalara, TaxJar) can be added by implementing Provider and +// switching on TAX_PROVIDER. +type Nordic struct { + defaultCountry string +} + +// NewNordic returns a Nordic provider. defaultCountry is used when +// DefaultTaxRate is called with an empty country code; it defaults to "SE" +// if empty. +func NewNordic(defaultCountry string) *Nordic { + if defaultCountry == "" { + defaultCountry = "SE" + } + return &Nordic{defaultCountry: defaultCountry} +} + +var _ Provider = (*Nordic)(nil) + +func (p *Nordic) Name() string { return "nordic" } + +func (p *Nordic) Compute(total int64, taxRate int) int64 { + return Compute(total, taxRate) +} + +func (p *Nordic) DefaultTaxRate(country string) int { + if country == "" { + country = p.defaultCountry + } + if rate, ok := nordicStandardRates[country]; ok { + return rate + } + return nordicStandardRates[p.defaultCountry] +} diff --git a/tax/price.go b/tax/price.go new file mode 100644 index 0000000..cf37da4 --- /dev/null +++ b/tax/price.go @@ -0,0 +1,175 @@ +package tax + +import ( + "encoding/json" + "strconv" + + "git.k6n.net/mats/platform/money" +) + +// Price is a monetary amount in minor units (money.Cents) with its VAT broken +// down by rate. IncVat is the gross total; VatRates maps each tax rate — basis +// points of a percent, 2500 = 25%, 1250 = 12.5% (the single platform scale, +// matching OrderLine.tax_rate) — to its VAT amount in minor units. +// +// All arithmetic is integer (money.Cents is int64-backed); VAT is only ever +// computed by Compute (ex-VAT-first), always on a real total. Aggregation +// (Add/SumPrices) only sums integers, so totals never drift from the sum of +// their parts. The JSON wire shape is unchanged — Cents marshals as int64. +type Price struct { + IncVat money.Cents `json:"incVat"` + VatRates map[int]money.Cents `json:"vat,omitempty"` +} + +// NewPrice returns a new zero Price. +func NewPrice() *Price { + return &Price{ + IncVat: 0, + VatRates: make(map[int]money.Cents), + } +} + +// NewPriceFromIncVat builds a Price from a gross total and a tax rate in basis +// points (2500 = 25%, 1250 = 12.5%). VAT is computed once via Compute, so +// exVat + vat == incVat exactly. +func NewPriceFromIncVat(incVat money.Cents, rateBp int) *Price { + return &Price{ + IncVat: incVat, + VatRates: map[int]money.Cents{ + rateBp: money.Cents(Compute(incVat.Int64(), rateBp)), + }, + } +} + +// ValueExVat returns the price excluding VAT. +func (p *Price) ValueExVat() money.Cents { + exVat := p.IncVat + for _, amount := range p.VatRates { + exVat -= amount + } + return exVat +} + +// TotalVat returns the total VAT amount. +func (p *Price) TotalVat() money.Cents { + total := money.Cents(0) + for _, amount := range p.VatRates { + total += amount + } + return total +} + +// MultiplyPrice scales a Price to a quantity and returns a new Price. +// +// For a single-rate price (a line item) it recomputes the VAT on the *scaled +// total* via NewPriceFromIncVat — not by multiplying the per-unit VAT — so the +// result matches a payment provider that re-derives tax from the line total, and +// VAT rounding never accumulates per unit. VAT is only ever computed on a real +// total. +// +// A zero-rate price scales its (untaxed) IncVat. A multi-rate price (which a line +// item never is — totals are built by Add, never Multiply) falls back to scaling +// each component, since IncVat can't be split across rates. +func MultiplyPrice(p Price, qty int64) *Price { + if len(p.VatRates) == 1 { + for rate := range p.VatRates { + return NewPriceFromIncVat(p.IncVat.Mul(qty), rate) + } + } + ret := &Price{ + IncVat: p.IncVat.Mul(qty), + VatRates: make(map[int]money.Cents, len(p.VatRates)), + } + for rate, amount := range p.VatRates { + ret.VatRates[rate] = amount.Mul(qty) + } + return ret +} + +// Multiply scales the Price by a quantity in place (see MultiplyPrice). +func (p *Price) Multiply(qty int64) { + res := MultiplyPrice(*p, qty) + p.IncVat = res.IncVat + p.VatRates = res.VatRates +} + +// MarshalJSON serializes Price as a wire-friendly object with exVat, incVat, and +// vat (rate-keyed by raw percent as a string, e.g. "25"). Cents marshals as +// int64, so the wire bytes are byte-identical to the pre-money.Cents shape. +func (p Price) MarshalJSON() ([]byte, error) { + exVat := p.ValueExVat() + var vat map[string]money.Cents + if len(p.VatRates) > 0 { + vat = make(map[string]money.Cents, len(p.VatRates)) + for rate, amount := range p.VatRates { + vat[strconv.Itoa(rate)] = amount + } + } + type wire struct { + ExVat money.Cents `json:"exVat"` + IncVat money.Cents `json:"incVat"` + Vat map[string]money.Cents `json:"vat,omitempty"` + } + return json.Marshal(wire{ExVat: exVat, IncVat: p.IncVat, Vat: vat}) +} + +// UnmarshalJSON deserializes Price from its wire format. +func (p *Price) UnmarshalJSON(data []byte) error { + type wire struct { + ExVat money.Cents `json:"exVat"` + IncVat money.Cents `json:"incVat"` + Vat map[string]money.Cents `json:"vat,omitempty"` + } + var w wire + if err := json.Unmarshal(data, &w); err != nil { + return err + } + + p.IncVat = w.IncVat + p.VatRates = make(map[int]money.Cents, len(w.Vat)) + for rateStr, amount := range w.Vat { + // Tolerate legacy float-style keys ("2500", "1250") by truncating to the + // basis point integer convention. + rate, err := strconv.Atoi(rateStr) + if err != nil { + f, ferr := strconv.ParseFloat(rateStr, 64) + if ferr != nil { + return err + } + rate = int(f) + } + p.VatRates[rate] = amount + } + return nil +} + +// Add adds another Price's values to this one. +func (p *Price) Add(price Price) { + p.IncVat += price.IncVat + for rate, amount := range price.VatRates { + p.VatRates[rate] += amount + } +} + +// Subtract subtracts another Price's values from this one. +func (p *Price) Subtract(price Price) { + p.IncVat -= price.IncVat + for rate, amount := range price.VatRates { + p.VatRates[rate] -= amount + } +} + +// SumPrices aggregates multiple Prices into one. +func SumPrices(prices ...Price) *Price { + aggregated := NewPrice() + for _, price := range prices { + aggregated.IncVat += price.IncVat + for rate, amount := range price.VatRates { + aggregated.VatRates[rate] += amount + } + } + if len(aggregated.VatRates) == 0 { + aggregated.VatRates = nil + } + return aggregated +} diff --git a/tax/tax.go b/tax/tax.go new file mode 100644 index 0000000..75766e5 --- /dev/null +++ b/tax/tax.go @@ -0,0 +1,62 @@ +// Package tax provides the tax computation seam: a Provider interface +// (mirroring PaymentProvider and ShippingProvider patterns), the shared Compute +// formula, and a stateless Static provider for dev/test. +// +// SCALE: all tax rates are integer **basis points of a percent** — rate × 100, +// so 25% = 2500, 12.5% = 1250, 8.1% = 810. This is the single platform-wide +// scale (Price.VatRates, CartItem.Tax, OrderLine.tax_rate, the payment-provider +// wire). Integer basis points support fractional VAT rates (Ireland 13.5%, +// France 5.5%, Finland 25.5%) exactly without ever using a float. +package tax + +// Provider computes taxes for orders and line items. +type Provider interface { + // Name returns the provider name for identification (logging, metrics). + Name() string + + // Compute computes the VAT portion from a gross (inc-VAT) total and tax + // rate. Both total and return value are in minor currency units (öre). + // rateBp is basis points of a percent (2500 = 25%, 1250 = 12.5%). + // + // Ex-VAT-first integer math: exVat = total*10000/(10000+rateBp); tax = total - exVat. + // When rateBp <= 0 the result is 0 (untaxed / zero-rated items). + Compute(total int64, rateBp int) int64 + + // DefaultTaxRate returns the default VAT rate for a country in basis points + // (2500 = 25%). Used for deliveries and items where no explicit rate was + // recorded at add-to-cart time. Returns 0 if the country is unknown/untaxed. + DefaultTaxRate(country string) int +} + +// Compute returns the VAT portion of an inc-VAT total using integer (cent) +// arithmetic only, ex-VAT-first so exVat + tax == total exactly and the result +// matches the payment-provider convention (no per-line rounding drift): +// +// exVat = total * 10000 / (10000 + rateBp) +// tax = total - exVat +// +// rateBp is basis points of a percent (2500 = 25%, 1250 = 12.5%) — the single +// platform-wide rate scale. When rateBp <= 0 the result is 0 (untaxed). +func Compute(total int64, rateBp int) int64 { + if rateBp <= 0 { + return 0 + } + exVat := total * 10000 / int64(10000+rateBp) + return total - exVat +} + +// Static is a stateless, always-available Provider that simply runs the +// formula without any country-specific rates. Use for local dev, tests, +// and as a no-dependency default. +type Static struct{} + +var _ Provider = (*Static)(nil) + +// NewStatic returns a new Static provider. +func NewStatic() *Static { return &Static{} } + +func (p *Static) Name() string { return "static" } +func (p *Static) Compute(total int64, taxRate int) int64 { + return Compute(total, taxRate) +} +func (p *Static) DefaultTaxRate(_ string) int { return 0 } diff --git a/tax/tax_test.go b/tax/tax_test.go new file mode 100644 index 0000000..6d4d966 --- /dev/null +++ b/tax/tax_test.go @@ -0,0 +1,223 @@ +package tax + +import ( + "encoding/json" + "testing" +) + +func TestStatic_Name(t *testing.T) { + p := NewStatic() + if got := p.Name(); got != "static" { + t.Errorf("Name() = %q, want %q", got, "static") + } +} + +func TestStatic_Compute(t *testing.T) { + // rateBp is basis points (2500 = 25%). + p := NewStatic() + tests := []struct { + total int64 + rateBp int + want int64 + }{ + {1250, 2500, 250}, + {25000, 2500, 5000}, + {1000, 2000, 167}, + {0, 2500, 0}, + } + for _, tt := range tests { + got := p.Compute(tt.total, tt.rateBp) + if got != tt.want { + t.Errorf("Compute(%d, %d) = %d; want %d", tt.total, tt.rateBp, got, tt.want) + } + } +} + +func TestStatic_DefaultTaxRate(t *testing.T) { + p := NewStatic() + if got := p.DefaultTaxRate("SE"); got != 0 { + t.Errorf("DefaultTaxRate(\"SE\") = %d; want 0", got) + } +} + +func TestNordic_Name(t *testing.T) { + p := NewNordic("SE") + if got := p.Name(); got != "nordic" { + t.Errorf("Name() = %q, want %q", got, "nordic") + } +} + +func TestNordic_Compute(t *testing.T) { + // rateBp is basis points (2500 = 25%). + tests := []struct { + total int64 + rateBp int + want int64 + desc string + }{ + {0, 2500, 0, "zero total"}, + {1250, 2500, 250, "25 % VAT on 1250"}, + {1000, 2000, 167, "20 % VAT on 1000"}, + {1200, 2500, 240, "25 % VAT on 1200"}, + {25000, 2500, 5000, "25 % VAT on 25000"}, + {100, 1000, 10, "10 % VAT on 100"}, + {100, 10000, 50, "100 % VAT on 100"}, + {100, 0, 0, "zero-rate"}, + {100, -1, 0, "negative rate -> zero"}, + } + for _, tt := range tests { + p := NewNordic("SE") + got := p.Compute(tt.total, tt.rateBp) + if got != tt.want { + t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.rateBp, tt.desc, got, tt.want) + } + } +} + +func TestNordic_DefaultTaxRate(t *testing.T) { + tests := []struct { + country string + want int + desc string + }{ + {"SE", 2500, "Sweden"}, + {"NO", 2500, "Norway"}, + {"DK", 2500, "Denmark"}, + {"FI", 2550, "Finland (25.5%)"}, + {"", 2500, "empty -> default SE"}, + {"DE", 2500, "unknown -> fallback SE"}, + } + for _, tt := range tests { + p := NewNordic("SE") + got := p.DefaultTaxRate(tt.country) + if got != tt.want { + t.Errorf("DefaultTaxRate(%q) [%s] = %d; want %d", tt.country, tt.desc, got, tt.want) + } + } +} + +func TestCompute(t *testing.T) { + // rateBp is basis points (2500 = 25%). + tests := []struct { + total int64 + rateBp int + want int64 + desc string + }{ + {1250, 2500, 250, "canonical: 25 %"}, + {25000, 2500, 5000, "25k with 25 % -> 5k tax"}, + {0, 2500, 0, "zero total"}, + {1000, 1250, 112, "fractional: 12.5 %"}, // exVat 1000*10000/11250=888, tax 112 + } + for _, tt := range tests { + got := Compute(tt.total, tt.rateBp) + if got != tt.want { + t.Errorf("Compute(%d, %d) [%s] = %d; want %d", tt.total, tt.rateBp, tt.desc, got, tt.want) + } + } +} + +func TestProviderInterface(t *testing.T) { + var p Provider = NewNordic("SE") + _ = p + p2 := NewStatic() + _ = p2 +} + +func TestPrice_NewAndString(t *testing.T) { + p := NewPrice() + if p.IncVat != 0 { + t.Errorf("NewPrice().IncVat = %d; want 0", p.IncVat) + } +} + +func TestPrice_NewPriceFromIncVat(t *testing.T) { + p := NewPriceFromIncVat(1250, 2500) // 25% in basis points + if p.IncVat != 1250 { + t.Errorf("IncVat = %d; want 1250", p.IncVat) + } + if len(p.VatRates) != 1 { + t.Errorf("expected 1 vat rate, got %d", len(p.VatRates)) + } +} + +func TestPrice_ValueExVat(t *testing.T) { + p := NewPriceFromIncVat(1250, 2500) // 25% + exVat := p.ValueExVat() + if exVat != 1000 { + t.Errorf("ValueExVat() = %d; want 1000", exVat) + } +} + +func TestPrice_TotalVat(t *testing.T) { + p := NewPriceFromIncVat(1250, 2500) // 25% + totalVat := p.TotalVat() + if totalVat != 250 { + t.Errorf("TotalVat() = %d; want 250", totalVat) + } +} + +func TestPrice_Multiply(t *testing.T) { + p := NewPriceFromIncVat(1250, 2500) + p.Multiply(2) + if p.IncVat != 2500 { + t.Errorf("IncVat after Multiply(2) = %d; want 2500", p.IncVat) + } +} + +func TestPrice_MultiplyPrice(t *testing.T) { + p := NewPriceFromIncVat(1250, 2500) + p2 := MultiplyPrice(*p, 3) + if p2.IncVat != 3750 { + t.Errorf("IncVat = %d; want 3750", p2.IncVat) + } +} + +func TestPrice_Add(t *testing.T) { + a := NewPriceFromIncVat(1250, 2500) + b := NewPriceFromIncVat(2500, 2500) + a.Add(*b) + if a.IncVat != 3750 { + t.Errorf("IncVat after Add = %d; want 3750", a.IncVat) + } +} + +func TestPrice_Subtract(t *testing.T) { + a := NewPriceFromIncVat(2500, 2500) + b := NewPriceFromIncVat(1250, 2500) + a.Subtract(*b) + if a.IncVat != 1250 { + t.Errorf("IncVat after Subtract = %d; want 1250", a.IncVat) + } +} + +func TestPrice_SumPrices(t *testing.T) { + a := NewPriceFromIncVat(1250, 2500) + b := NewPriceFromIncVat(2500, 2500) + total := SumPrices(*a, *b) + if total.IncVat != 3750 { + t.Errorf("SumPrices IncVat = %d; want 3750", total.IncVat) + } +} + +func TestPrice_JSONRoundTrip(t *testing.T) { + p := NewPriceFromIncVat(1250, 2500) + data, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + var p2 Price + if err := json.Unmarshal(data, &p2); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if p2.IncVat != p.IncVat { + t.Errorf("IncVat mismatch: have %d, want %d", p2.IncVat, p.IncVat) + } +} + +func TestPrice_SumPricesEmpty(t *testing.T) { + total := SumPrices() + if total.IncVat != 0 { + t.Errorf("SumPrices() IncVat = %d; want 0", total.IncVat) + } +} diff --git a/uid/uid.go b/uid/uid.go new file mode 100644 index 0000000..a1d2d09 --- /dev/null +++ b/uid/uid.go @@ -0,0 +1,130 @@ +// Package uid provides a compact 64-bit identifier with base62 string form. +// +// The scheme is used consistently across commerce services (cart, order, +// profile, actor grains) so ids are URL/cookie-friendly and comparable +// across system boundaries. Each domain re-exports the type as its own +// named alias (e.g. order.OrderId = uid.ID) and keeps thin wrapper +// functions so call-site code never needs to import platform/uid directly. +package uid + +import ( + "crypto/rand" + "encoding/json" + "fmt" +) + +// ID is a 64-bit identifier encoded as base62 text (max 11 chars). +type ID uint64 + +const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +var base62Rev [256]byte + +func init() { + for i := range base62Rev { + base62Rev[i] = 0xFF + } + for i := 0; i < len(base62Alphabet); i++ { + base62Rev[base62Alphabet[i]] = byte(i) + } +} + +// String returns the canonical base62 encoding. +func (id ID) String() string { return encodeBase62(uint64(id)) } + +// MarshalJSON encodes the id as a JSON string. +func (id ID) MarshalJSON() ([]byte, error) { + return json.Marshal(id.String()) +} + +// UnmarshalJSON decodes the id from a JSON string containing base62 text. +func (id *ID) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsed, ok := Parse(s) + if !ok { + return fmt.Errorf("invalid uid: %q", s) + } + *id = parsed + return nil +} + +// New generates a cryptographically random non-zero 64-bit id. +func New() (ID, error) { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return 0, fmt.Errorf("uid.New: %w", err) + } + u := (uint64(b[0]) << 56) | + (uint64(b[1]) << 48) | + (uint64(b[2]) << 40) | + (uint64(b[3]) << 32) | + (uint64(b[4]) << 24) | + (uint64(b[5]) << 16) | + (uint64(b[6]) << 8) | + uint64(b[7]) + if u == 0 { + return New() + } + return ID(u), nil +} + +// MustNew panics if generation fails. +func MustNew() ID { + id, err := New() + if err != nil { + panic(err) + } + return id +} + +// Parse parses a base62 string into an ID. Returns (0, false) for invalid input. +func Parse(s string) (ID, bool) { + if len(s) == 0 || len(s) > 11 { + return 0, false + } + u, ok := decodeBase62(s) + if !ok { + return 0, false + } + return ID(u), true +} + +// MustParse panics on invalid base62 input. +func MustParse(s string) ID { + id, ok := Parse(s) + if !ok { + panic(fmt.Sprintf("invalid uid: %q", s)) + } + return id +} + +// encodeBase62 converts a uint64 to base62 (shortest form, no leading zeros). +func encodeBase62(u uint64) string { + if u == 0 { + return "0" + } + var buf [11]byte + i := len(buf) + for u > 0 { + i-- + buf[i] = base62Alphabet[u%62] + u /= 62 + } + return string(buf[i:]) +} + +// decodeBase62 converts base62 text to uint64. +func decodeBase62(s string) (uint64, bool) { + var v uint64 + for i := 0; i < len(s); i++ { + d := base62Rev[s[i]] + if d == 0xFF { + return 0, false + } + v = v*62 + uint64(d) + } + return v, true +}