This commit is contained in:
mats committed 2026-06-28 17:52:49 +02:00
1 parent e706f7d907
commit 21bcb63440
42 files changed
+5903

No files matched your search

+73
View File
@@ -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 <pod> -- /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"]
+386
View File
@@ -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)
}
+162
View File
@@ -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)
}
}
+41
View File
@@ -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
+134
View File
@@ -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>")
// 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
}
+144
View File
@@ -0,0 +1,144 @@
// Registry is the per-tenant Schema lookup. A producer processes tenant X:
// it builds an event.Event with Source="catalog:<tenant>" 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
}
+107
View File
@@ -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)
}
}
+171
View File
@@ -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"}
}
+96
View File
@@ -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")
}
}
+423
View File
@@ -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
}
+22
View File
@@ -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)
}
+201
View File
@@ -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()
}
}
}
+177
View File
@@ -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")
}
}
+17
View File
@@ -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
}
+448
View File
@@ -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(),
}
}
+61
View File
@@ -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
}
+571
View File
@@ -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 // <out-dir>/<tenant>.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 <out-dir>/<tenant>.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:<x>" 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:<tenant>\" 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>" → 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)
}
+314
View File
@@ -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:<tenant>" 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)
}
+138
View File
@@ -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 ""
}
+95
View File
@@ -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
+5
View File
@@ -1,3 +1,8 @@
module git.k6n.net/mats/platform module git.k6n.net/mats/platform
go 1.26.2 go 1.26.2
require (
github.com/rabbitmq/amqp091-go v1.11.0
golang.org/x/sys v0.46.0
)
+6
View File
@@ -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=
+66
View File
@@ -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
}
+47
View File
@@ -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)
}
}
+47
View File
@@ -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}}
}
+55
View File
@@ -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
}
+181
View File
@@ -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)
}
+51
View File
@@ -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
}
+40
View File
@@ -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
}
+143
View File
@@ -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) }
+129
View File
@@ -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)
}
}
+53
View File
@@ -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
}
+30
View File
@@ -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])
}
}
+265
View File
@@ -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 "<country>_<topic>", 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
}
}
}
+258
View File
@@ -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()
}
+64
View File
@@ -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
}
+36
View File
@@ -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"
}
}
+56
View File
@@ -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]
}
+175
View File
@@ -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
}
+62
View File
@@ -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 }
+223
View File
@@ -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)
}
}
+130
View File
@@ -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
}