Compare commits
18
Commits
83986e7a35
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150ac635c | ||
|
|
80a1e6d05e | ||
|
|
9f1eca07e9 | ||
|
|
e20793a6b3 | ||
|
|
781c1bd171 | ||
|
|
e2f835c01b | ||
|
|
eed53c96fb | ||
|
|
6180706140 | ||
|
|
31530be28f | ||
|
|
dddb5c699a | ||
|
|
8bb0a7a786 | ||
|
|
b6a67e709d | ||
|
|
cecf4abe76 | ||
|
|
f339b60351 | ||
|
|
2e2060da5c | ||
|
|
26fd6dc970 | ||
|
|
1c97a4bdb0 | ||
|
|
4a37cb479c |
@@ -0,0 +1,102 @@
|
|||||||
|
# go-cart-actor — agents map
|
||||||
|
|
||||||
|
The **commercial heart** of the platform. Orleans-style grain actors for cart,
|
||||||
|
checkout, order, profile, inventory, plus a **UCP (Universal Commerce
|
||||||
|
Protocol) HTTP adapter** at `internal/ucp/`. Deploys multiple binaries in
|
||||||
|
parallel (one per concern) coordinated via k8s and an internal actor
|
||||||
|
discovery/RPC layer. See `docs/checkout-order-handoff.md` for the role split
|
||||||
|
with `go-order-manager`.
|
||||||
|
|
||||||
|
## Where to start
|
||||||
|
|
||||||
|
- `cmd/cart/main.go` — the one most commonly run in dev.
|
||||||
|
- `pkg/actor/` — the grain framework (start with `grain.go`, then
|
||||||
|
`simple_grain_pool.go` and `grain_pool.go`).
|
||||||
|
- `pkg/cart/cart-grain.go` — a concrete grain, the cleanest example.
|
||||||
|
- `internal/ucp/handler.go` and `ucp/{cart,checkout,order,profile}.go` —
|
||||||
|
the protocol adapter over the grains.
|
||||||
|
- `go-cart-actor/README.md`, `TODO.md`, `NEW_MUTATIONS_SPEC.md` — current
|
||||||
|
intent and known gaps.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Grain model**: each domain entity (cart, order, checkout…) is a grain
|
||||||
|
keyed by an opaque ID. Per-aggregate state machine lives in the grain's
|
||||||
|
`state.go`; mutations are individual `mutation_*.go` files under
|
||||||
|
`pkg/<domain>/` and registered through `mutation_registry.go`.
|
||||||
|
- **RPC**: gRPC over [`proto/`](proto/) — `cart.proto`, `checkout.proto`,
|
||||||
|
`order.proto`, `profile.proto`, `control_plane.proto`. Pod discovery via
|
||||||
|
`pkg/discovery/` + `pkg/proxy/remotehost.go` (k8s host discovery in
|
||||||
|
`cmd/<bin>/k8s-host-discovery.go`).
|
||||||
|
- **Binaries** (`cmd/`):
|
||||||
|
- `cart/`, `checkout/`, `order/`, `profile/`, `inventory/` — one each.
|
||||||
|
- `backoffice/` — the admin UI host, plugged into `backoffice` via
|
||||||
|
`pkg/backofficeadmin/`.
|
||||||
|
- `ucp-artifacts/` — emits the protocol artefacts (signing, handler
|
||||||
|
bindings).
|
||||||
|
- **UCP**: `internal/ucp/` is the protocol adapter — it talks to the grains
|
||||||
|
via RPC, not directly. Separate from `pkg/{cart,checkout,…}`.
|
||||||
|
- **Cross-cutting**: `pkg/outbox/`, `pkg/idempotency/`, `pkg/flow/`,
|
||||||
|
`pkg/promotions/`, `pkg/voucher/`, `pkg/telemetry/`.
|
||||||
|
- **Auth**: `internal/customerauth/` — password hashing (`password.go`),
|
||||||
|
rate limit, mail notifier, redis store, opaque customer session tokens.
|
||||||
|
|
||||||
|
## Important files by area
|
||||||
|
|
||||||
|
| Area | File(s) |
|
||||||
|
| -------------------------- | ------------------------------------------------------ |
|
||||||
|
| Grain framework | `pkg/actor/{grain,grain_pool,simple_grain_pool,state,disk_storage}.go` |
|
||||||
|
| Grain RPC | `pkg/actor/{grpc_server,grpc_server_test}.go` |
|
||||||
|
| Cart mutations | `pkg/cart/mutation_*.go` (+ `cart-grain.go`) |
|
||||||
|
| Order mutations | `pkg/order/mutation_*.go` + `pkg/order/order-grain.go` |
|
||||||
|
| Order outbox / payment | `pkg/order/{emit_created,payment,stripe,klarna}_*.go` |
|
||||||
|
| Promotions / vouchers | `pkg/promotions/`, `pkg/voucher/` |
|
||||||
|
| Idempotency | `pkg/idempotency/store.go` |
|
||||||
|
| Outbox (event publish) | `pkg/outbox/outbox.go` |
|
||||||
|
| UCP protocol adapter | `internal/ucp/{handler,cart,checkout,order,customer,profile}.go`, `signing.go` |
|
||||||
|
| Backoffice admin | `pkg/backofficeadmin/{app,hub,fileserver,customer}.go` |
|
||||||
|
| Customer auth | `internal/customerauth/{server,redis,password,tokens,mail_notifier,ratelimit}.go` |
|
||||||
|
| Telemetry / OTel | `pkg/telemetry/{otel,logs}.go` |
|
||||||
|
| k8s service discovery | `pkg/discovery/discovery.go`, `pkg/proxy/remotehost.go`, `cmd/<bin>/k8s-host-discovery.go` |
|
||||||
|
| Tests integration scaffold | `cmd/cart/projection_*_test.go`, `pkg/promotions/apply_test.go` |
|
||||||
|
|
||||||
|
## Cross-project seams
|
||||||
|
|
||||||
|
- **`platform/`**: uses `platform/auth`, `platform/catalog`, `platform/event`,
|
||||||
|
`platform/inventory`, `platform/order`, `platform/tax`, `platform/rabbit`.
|
||||||
|
- **`go-order-manager`**: subscribes to order events off the RabbitMQ
|
||||||
|
topology (`docs/checkout-order-handoff.md`).
|
||||||
|
- **`go-shipping`**: pulled via HTTP from `cart` (the cart client).
|
||||||
|
- **`slask-tracking`**: independent consumer of order events for analytics.
|
||||||
|
- **`backoffice`** (commerce tag): exposes the `pkg/backofficeadmin/` host.
|
||||||
|
|
||||||
|
## Improvement suggestions
|
||||||
|
|
||||||
|
- **Mutation file explosion**: each behavior of a grain lives in its own
|
||||||
|
`mutation_*.go`. This is shallow — the deletion test says removing one just
|
||||||
|
moves the same code to its sibling file. Consider grouping related
|
||||||
|
mutations per feature slice (e.g. one `cart_subscription.go` for
|
||||||
|
add/remove/upsert subscriptiondetails), so the test surface and locality
|
||||||
|
live together.
|
||||||
|
- **`pkg/cart` vs `internal/ucp` vs `pkg/checkout`** — three layers touch the
|
||||||
|
same conceptual "cart" entity. The UCP adapter depends on the grain, the
|
||||||
|
grain depends on the protocol-specific mutations, and the checkout layer
|
||||||
|
brokers a different view of the cart. Drop the conceptual split here:
|
||||||
|
consider promoting one domain (`cart`) as the seam and treating the others
|
||||||
|
as adapters.
|
||||||
|
- **Inconsistent layout**: most binaries are at `cmd/<name>/main.go`, but
|
||||||
|
`internal/ucp/`, `internal/customerauth/`, and `pkg/voucher/`, `pkg/outbox/`
|
||||||
|
sit at different depths. A short module-layout doc would prevent the next
|
||||||
|
contributor from guessing where new helpers go.
|
||||||
|
- **`TODO.md` and `NEW_MUTATIONS_SPEC.md` live in the repo root.** Both belong
|
||||||
|
in `docs/` so the README is the only thing newcomers hit first.
|
||||||
|
- **Two proto variants**: `proto/checkout.proto` and
|
||||||
|
`proto/checkout/checkout.proto`. Likely a historical artefact (see
|
||||||
|
`proto/README` if it exists, otherwise grep `proto/` for `checkout` to see
|
||||||
|
which is actually imported).
|
||||||
|
- **Test coverage is uneven across `pkg/*`** — vendor-friendly modules like
|
||||||
|
`pkg/promotions/` have `_test.go`, others like `pkg/voucher/` rely on
|
||||||
|
`parser_test.go` only. Worth a pass to seed unit tests at each public
|
||||||
|
function entry.
|
||||||
|
- **`cookies.txt` at repo root** is not a build artefact and shouldn't be
|
||||||
|
committed.
|
||||||
+140
-61
@@ -17,6 +17,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart/recovery"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||||
@@ -26,18 +27,17 @@ import (
|
|||||||
"git.k6n.net/mats/platform/config"
|
"git.k6n.net/mats/platform/config"
|
||||||
"git.k6n.net/mats/platform/event"
|
"git.k6n.net/mats/platform/event"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
// cartMetrics is the shared prometheus instrumentation for the
|
||||||
Name: "cart_grain_spawned_total",
|
// cart actor pool and event log. Built once at process start; nil
|
||||||
Help: "The total number of spawned grains",
|
// is not expected in production but the actor package's
|
||||||
})
|
// touch sites are nil-safe so a test binary can pass nil.
|
||||||
|
cartMetrics = actor.NewMetrics("cart")
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -171,7 +171,11 @@ func main() {
|
|||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
|
|
||||||
promotionStore, err := promotions.NewStore("data/promotions.json")
|
promotionsPath := os.Getenv("PROMOTIONS_FILE")
|
||||||
|
if promotionsPath == "" {
|
||||||
|
promotionsPath = "data/promotions.json"
|
||||||
|
}
|
||||||
|
promotionStore, err := promotions.NewStore(promotionsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error loading promotions: %v\n", err)
|
log.Fatalf("Error loading promotions: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -198,72 +202,31 @@ func main() {
|
|||||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
// Canonical promotion pipeline — same call site as the
|
||||||
// Clear bypass flags first
|
// /promotions/evaluate-with-cart preview handler in
|
||||||
for _, v := range g.Vouchers {
|
// promotions_evaluate.go. Going through the shared
|
||||||
if v != nil {
|
// PromotionService.EvaluateAndApply means the bypass
|
||||||
v.BypassedByPromotions = false
|
// loop, the re-eval trigger, and ApplyResults stay in
|
||||||
}
|
// lockstep with the preview so a what-if always matches
|
||||||
}
|
// post-add behavior (including the coupon+promotion-
|
||||||
|
// overlap edge case). If you find yourself adding
|
||||||
g.UpdateTotals()
|
// promotion logic here, add it to EvaluateAndApply
|
||||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
// instead — the preview must follow.
|
||||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now()))
|
||||||
|
|
||||||
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
|
|
||||||
hasBypassed := false
|
|
||||||
for _, res := range results {
|
|
||||||
if res.Applicable {
|
|
||||||
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
|
||||||
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
|
||||||
var codes []string
|
|
||||||
if s, ok := bc.Value.AsString(); ok {
|
|
||||||
codes = append(codes, strings.ToLower(s))
|
|
||||||
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
|
||||||
for _, s := range arr {
|
|
||||||
codes = append(codes, strings.ToLower(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, code := range codes {
|
|
||||||
for _, v := range g.Vouchers {
|
|
||||||
if v != nil && strings.ToLower(v.Code) == code {
|
|
||||||
if !v.BypassedByPromotions {
|
|
||||||
v.BypassedByPromotions = true
|
|
||||||
hasBypassed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasBypassed {
|
|
||||||
// Re-evaluate with bypassed vouchers
|
|
||||||
g.UpdateTotals()
|
|
||||||
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
|
||||||
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyResults applies qualifying actions in priority order and records
|
|
||||||
// every effect — both applied discounts and pending "spend X more for ..."
|
|
||||||
// nudges with their progress — in g.AppliedPromotions.
|
|
||||||
promotionService.ApplyResults(g, results, promotionCtx)
|
|
||||||
g.Version++
|
g.Version++
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
|
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
|
||||||
|
diskStorage.SetMetrics(cartMetrics)
|
||||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
|
Metrics: cartMetrics,
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
||||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
||||||
defer span.End()
|
defer span.End()
|
||||||
grainSpawns.Inc()
|
|
||||||
ret := cart.NewCartGrain(id, time.Now())
|
ret := cart.NewCartGrain(id, time.Now())
|
||||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||||
|
|
||||||
@@ -389,6 +352,65 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Data Retention (C5) GDPR Purge
|
||||||
|
retentionDays := config.EnvInt("CART_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||||
|
if retentionDays > 0 {
|
||||||
|
purgeInterval := config.EnvDuration("CART_PURGE_INTERVAL", 24*time.Hour)
|
||||||
|
log.Printf("cart: data retention enabled. Retaining carts for %d days, purging every %v", retentionDays, purgeInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(purgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("cart: data retention / GDPR purge disabled (CART_RETENTION_DAYS not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abandoned-cart recovery check (C3-notifications seam, dry-run by default).
|
||||||
|
// Scans local pod event-log files for carts whose modtime falls into the
|
||||||
|
// "[now - delay - window, now - delay]" window and that have items + a
|
||||||
|
// contact (email and/or push tokens). Hands each candidate to the
|
||||||
|
// configured Notifier (LoggingNotifier v0). The scanner reads local shard
|
||||||
|
// files only — the control plane doesn't fan out between pods on purpose
|
||||||
|
// (each pod owns the carts it is the owner of). Idempotency is the next
|
||||||
|
// pass; the v0 window size keeps duplicate notifications minute-scoped.
|
||||||
|
abandonedDelay := config.EnvDuration("CART_ABANDONED_DELAY", time.Hour)
|
||||||
|
if abandonedDelay > 0 {
|
||||||
|
abandonedWindow := config.EnvDuration("CART_ABANDONED_WINDOW", time.Hour)
|
||||||
|
abandonedInterval := config.EnvDuration("CART_ABANDONED_SCAN_INTERVAL", 15*time.Minute)
|
||||||
|
notifier := recovery.NewLoggingNotifier()
|
||||||
|
scanner := recovery.NewScanner(config.EnvString("CART_DIR", "data"), diskStorage)
|
||||||
|
log.Printf("cart: abandonment recovery ENABLED. Delay=%v window=%v scan_interval=%v (notifier=LoggingNotifier dry-run)", abandonedDelay, abandonedWindow, abandonedInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(abandonedInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("cart: abandonment recovery DISABLED (CART_ABANDONED_DELAY not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
@@ -426,6 +448,19 @@ func main() {
|
|||||||
// without creating or mutating a real cart.
|
// without creating or mutating a real cart.
|
||||||
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
||||||
|
|
||||||
|
// Cart-aware promotion evaluation: reads the cartid cookie the storefront
|
||||||
|
// already maintains, fetches the actual cart grain (cross-pod via
|
||||||
|
// GetAnywhere), merges with the request's items (the items the user is
|
||||||
|
// considering adding), and runs the engine on the merged line list. The
|
||||||
|
// cart is the source of truth (vouchers, customer segment, customer
|
||||||
|
// lifetime value, total) so a serialised copy from the browser can race
|
||||||
|
// the live cart — letting the backend read the cookie + fetch the grain
|
||||||
|
// means exactly one source of truth and no race. Missing cookie or fetch
|
||||||
|
// failure gracefully degrades to a stateless evaluation of the request
|
||||||
|
// items alone. See newPromotionEvaluateWithCartHandler in
|
||||||
|
// promotions_evaluate.go for the full contract.
|
||||||
|
mux.HandleFunc("POST /promotions/evaluate-with-cart", newPromotionEvaluateWithCartHandler(promotionStore, promotionService, syncedServer))
|
||||||
|
|
||||||
// only for local
|
// only for local
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
pool.AddRemote(r.PathValue("host"))
|
pool.AddRemote(r.PathValue("host"))
|
||||||
@@ -535,3 +570,47 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[cart.CartGrain], pool *actor.SimpleGrainPool[cart.CartGrain], retentionDays int) {
|
||||||
|
log.Printf("cart: starting data retention purge...")
|
||||||
|
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||||
|
|
||||||
|
localIds := make(map[uint64]bool)
|
||||||
|
for _, id := range pool.GetLocalIds() {
|
||||||
|
localIds[id] = true
|
||||||
|
}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return localIds[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cart: data retention purge failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("cart: data retention purge completed. Purged %d expired cart logs", purged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRecovery is the abandoned-cart scanner pass. It finds carts that crossed
|
||||||
|
// the abandonment threshold in the recent window and hands each to the
|
||||||
|
// configured Notifier. Each pass is independent; idempotency across ticks is
|
||||||
|
// the next pass (logged duplicates are not catastrophic for the dry-run
|
||||||
|
// notifier, but a real MailerSend/FCM impl will need a SETNX side-key).
|
||||||
|
func runRecovery(ctx context.Context, scanner *recovery.Scanner, notifier recovery.Notifier, delay, window time.Duration) {
|
||||||
|
candidates, err := scanner.Scan(ctx, delay, window, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cart: recovery scan failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
log.Printf("cart: recovery scan complete, 0 candidates (window delay=%v width=%v)", delay, window)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("cart: recovery scan complete, %d candidates", len(candidates))
|
||||||
|
for _, c := range candidates {
|
||||||
|
if err := notifier.NotifyAbandoned(ctx, c); err != nil {
|
||||||
|
log.Printf("cart: recovery notify (cart %d) failed: %v", c.CartID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+64
-15
@@ -16,8 +16,6 @@ import (
|
|||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
@@ -28,16 +26,13 @@ import (
|
|||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
// cart_mutations_total, cart_remote_negotiation_total,
|
||||||
Name: "cart_grain_mutations_total",
|
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
|
||||||
Help: "The total number of mutations",
|
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
|
||||||
})
|
// registered once in cmd/cart/main.go and shared with DiskStorage
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
// via SetMetrics, so the per-handler counters this file used to
|
||||||
Name: "cart_grain_lookups_total",
|
// maintain are no longer needed.
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type PoolServer struct {
|
type PoolServer struct {
|
||||||
actor.GrainPool[cart.CartGrain]
|
actor.GrainPool[cart.CartGrain]
|
||||||
@@ -122,10 +117,17 @@ func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
grainMutations.Add(float64(len(data.Mutations)))
|
|
||||||
return s.WriteResult(w, data)
|
return s.WriteResult(w, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
|
||||||
|
// cart_mutations_total, cart_remote_negotiation_total,
|
||||||
|
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
|
||||||
|
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
|
||||||
|
// registered once in cmd/cart/main.go and shared with DiskStorage
|
||||||
|
// via SetMetrics, so the per-handler counters this file used to
|
||||||
|
// maintain are no longer needed.
|
||||||
|
|
||||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
@@ -496,9 +498,8 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
|||||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||||
span.SetAttributes(hostAttr)
|
span.SetAttributes(hostAttr)
|
||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
||||||
|
|
||||||
grainLookups.Inc()
|
|
||||||
if err == nil && handled {
|
if err == nil && handled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -605,6 +606,49 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRecoveryContactRequest is the wire shape clients send to
|
||||||
|
// PUT /cart/recovery-contact. It mirrors proto SetRecoveryContact with
|
||||||
|
// JSON-friendly types; sending an empty JSON object clears all contact info.
|
||||||
|
type SetRecoveryContactRequest struct {
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
PushTokens []cart.PushToken `json:"pushTokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toMessage builds the proto message from the JSON request body.
|
||||||
|
func (s *SetRecoveryContactRequest) toMessage() *messages.SetRecoveryContact {
|
||||||
|
out := &messages.SetRecoveryContact{Email: s.Email}
|
||||||
|
if len(s.PushTokens) > 0 {
|
||||||
|
out.PushTokens = make([]*messages.PushToken, 0, len(s.PushTokens))
|
||||||
|
for _, t := range s.PushTokens {
|
||||||
|
out.PushTokens = append(out.PushTokens, &messages.PushToken{
|
||||||
|
Platform: t.Platform,
|
||||||
|
Token: t.Token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PoolServer) SetRecoveryContactHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
|
if r.Method != http.MethodPut {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req := SetRecoveryContactRequest{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reply, err := s.ApplyLocal(r.Context(), cartId, req.toMessage())
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.WriteResult(w, reply)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
setUserId := messages.SetUserId{}
|
setUserId := messages.SetUserId{}
|
||||||
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
||||||
@@ -747,6 +791,10 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
||||||
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||||
|
// PUT only — SetRecoveryContact is PUT/replace semantics. We deliberately
|
||||||
|
// do NOT allow POST here to avoid conflicts with any future POST→create
|
||||||
|
// semantics on /cart paths.
|
||||||
|
handleFunc("PUT /cart/recovery-contact", CookieCartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
|
||||||
|
|
||||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||||
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||||
@@ -763,6 +811,7 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||||
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||||
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||||
|
handleFunc("PUT /cart/byid/{id}/recovery-contact", CartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
|
||||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||||
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWriteResult_CartGrainCarriesEvaluatedItems verifies that the per-line
|
||||||
|
// promotion breakdown lives on CartGrain.EvaluatedItems (so the canonical
|
||||||
|
// promotion pipeline populates it once per mutation, not at every read
|
||||||
|
// path) and naturally serialises through WriteResult alongside the rest
|
||||||
|
// of the grain's tagged fields. There is no envelope wrapper anymore —
|
||||||
|
// the grain's own JSON tag carries the field. See pkg/cart/evaluated_item.go
|
||||||
|
// for EvaluatedItem semantics and pkg/cart/cart-grain.go for the EvaluatedItems
|
||||||
|
// field declaration.
|
||||||
|
func TestWriteResult_CartGrainCarriesEvaluatedItems(t *testing.T) {
|
||||||
|
grain := &cart.CartGrain{
|
||||||
|
Id: 1,
|
||||||
|
Currency: "SEK",
|
||||||
|
Items: []*cart.CartItem{
|
||||||
|
{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||||
|
Discount: cart.NewPriceFromIncVat(2000, 500),
|
||||||
|
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TotalPrice: cart.NewPriceFromIncVat(20000, 5000),
|
||||||
|
TotalDiscount: cart.NewPriceFromIncVat(4000, 1000),
|
||||||
|
EvaluatedItems: []cart.EvaluatedItem{{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Name: "Promo Item",
|
||||||
|
Quantity: 2,
|
||||||
|
PriceIncVat: 10000,
|
||||||
|
OrgPriceIncVat: 12000,
|
||||||
|
DiscountIncVat: 2000,
|
||||||
|
EffectivePriceIncVat: 9000,
|
||||||
|
EffectiveTotalIncVat: 18000,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &PoolServer{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if err := s.WriteResult(w, grain); err != nil {
|
||||||
|
t.Fatalf("WriteResult: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{"id", "currency", "items", "totalPrice", "totalDiscount", "evaluatedItems"} {
|
||||||
|
if _, ok := resp[key]; !ok {
|
||||||
|
t.Fatalf("expected top-level %q in grain serialisation, got keys %v", key, mapKeys(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ev []struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
DiscountIncVat int64 `json:"discountIncVat"`
|
||||||
|
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"`
|
||||||
|
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp["evaluatedItems"], &ev); err != nil {
|
||||||
|
t.Fatalf("evaluatedItems parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(ev) != 1 || ev[0].Sku != "promo-1" {
|
||||||
|
t.Fatalf("expected one promo-1 evaluated item, got %+v", ev)
|
||||||
|
}
|
||||||
|
if ev[0].DiscountIncVat != 2000 || ev[0].EffectiveTotalIncVat != 18000 || ev[0].EffectivePriceIncVat != 9000 {
|
||||||
|
t.Fatalf("evaluatedItem math off: %+v", ev[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteResult_MutationResultNestsEvaluatedItems verifies the natural
|
||||||
|
// post-mutation wire shape: actor.MutationResult[cart.CartGrain] serialises
|
||||||
|
// Result (the grain) and Mutations (applied-change metadata) at the existing
|
||||||
|
// top-level positions, and CartGrain's EvaluatedItems rides inside Result
|
||||||
|
// because the grain owns the field. Previously the envelope wrapper pulled
|
||||||
|
// evaluatedItems out as a sibling of {result, mutations}; the natural
|
||||||
|
// nesting is what the user wanted ("no strange patterns on a global level").
|
||||||
|
// Internal/admin tools that read response.evaluatedItems will need to read
|
||||||
|
// response.result.evaluatedItems instead. Wrap-around breakage acknowledged.
|
||||||
|
func TestWriteResult_MutationResultNestsEvaluatedItems(t *testing.T) {
|
||||||
|
grain := &cart.CartGrain{
|
||||||
|
Id: 1,
|
||||||
|
Currency: "SEK",
|
||||||
|
Items: []*cart.CartItem{
|
||||||
|
{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 1,
|
||||||
|
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||||
|
Discount: cart.NewPriceFromIncVat(3000, 750),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EvaluatedItems: []cart.EvaluatedItem{{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 1,
|
||||||
|
PriceIncVat: 10000,
|
||||||
|
DiscountIncVat: 3000,
|
||||||
|
EffectiveTotalIncVat: 7000,
|
||||||
|
EffectivePriceIncVat: 7000,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
mr := &actor.MutationResult[cart.CartGrain]{
|
||||||
|
Result: *grain,
|
||||||
|
Mutations: []actor.ApplyResult{{Type: "cart.AddItem"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &PoolServer{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if err := s.WriteResult(w, mr); err != nil {
|
||||||
|
t.Fatalf("WriteResult: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// {result, mutations} are the existing envelope — preserved.
|
||||||
|
for _, key := range []string{"result", "mutations"} {
|
||||||
|
if _, ok := resp[key]; !ok {
|
||||||
|
t.Fatalf("expected top-level %q in mutation response, got %v", key, mapKeys(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// evaluatedItems no longer rises to the top level (the envelope is
|
||||||
|
// gone). Confirm absence at the top — it's now nested under result.
|
||||||
|
if _, hasTop := resp["evaluatedItems"]; hasTop {
|
||||||
|
t.Fatalf("evaluatedItems leaked to top level — envelope wrapper returned somehow")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result nests the grain, including EvaluatedItems naturally.
|
||||||
|
var inner map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(resp["result"], &inner); err != nil {
|
||||||
|
t.Fatalf("result parse: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := inner["items"]; !ok {
|
||||||
|
t.Fatalf("result.items missing — Result did not carry the grain")
|
||||||
|
}
|
||||||
|
evRaw, ok := inner["evaluatedItems"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("result.evaluatedItems missing — grain field should nest under result")
|
||||||
|
}
|
||||||
|
var ev []struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
DiscountIncVat int64 `json:"discountIncVat"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(evRaw, &ev); err != nil {
|
||||||
|
t.Fatalf("result.evaluatedItems parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(ev) != 1 || ev[0].Sku != "promo-1" || ev[0].DiscountIncVat != 3000 {
|
||||||
|
t.Fatalf("result.evaluatedItems wrong: %+v", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutations are still a sibling of result.
|
||||||
|
var muts []map[string]any
|
||||||
|
if err := json.Unmarshal(resp["mutations"], &muts); err != nil {
|
||||||
|
t.Fatalf("mutations parse: %v", err)
|
||||||
|
}
|
||||||
|
if len(muts) != 1 || muts[0]["type"] != "cart.AddItem" {
|
||||||
|
t.Fatalf("mutations wrong: %+v", muts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteResult_NoEvaluatedItemsOmitsField verifies the EvaluatedItems
|
||||||
|
// omitempty on CartGrain drops the field entirely when the canonical
|
||||||
|
// pipeline hasn't run (synthetic grain in a fixture, fresh spawn before
|
||||||
|
// any promotion-aware mutation). Clients reading evaluatedItems can
|
||||||
|
// rely on absence to mean "no promotion compute yet".
|
||||||
|
func TestWriteResult_NoEvaluatedItemsOmitsField(t *testing.T) {
|
||||||
|
grain := &cart.CartGrain{
|
||||||
|
Id: 1,
|
||||||
|
Currency: "SEK",
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &PoolServer{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if err := s.WriteResult(w, grain); err != nil {
|
||||||
|
t.Fatalf("WriteResult: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if _, has := resp["evaluatedItems"]; has {
|
||||||
|
t.Fatalf("expected evaluatedItems omitted on a grain with nil breakdown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapKeys returns the sorted set of top-level keys for nicer failure output.
|
||||||
|
func mapKeys(m map[string]json.RawMessage) []string {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ const minGlassArea = 0.4 // m²
|
|||||||
// areaFromItem derives the main article's *visible glass* surface in m² from a
|
// areaFromItem derives the main article's *visible glass* surface in m² from a
|
||||||
// resolved configurator selection. The catalog width/height are facet codes;
|
// resolved configurator selection. The catalog width/height are facet codes;
|
||||||
// the outer (karmytter) size is `code × 100 − margin` mm and the visible glass
|
// the outer (karmytter) size is `code × 100 − margin` mm and the visible glass
|
||||||
// is that minus the frame border per axis, e.g. width 4, widthMargin 20,
|
// is that minus the frame border per axis, e.g. width 4, marginWidth 20,
|
||||||
// borderWidth 194 → 400 − 20 − 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
|
// borderWidth 194 → 400 − 20 − 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
|
||||||
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
|
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
|
||||||
// is missing/non-numeric (non-window PDP, or no product resolved yet).
|
// is missing/non-numeric (non-window PDP, or no product resolved yet).
|
||||||
@@ -193,14 +193,14 @@ func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, q
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := &messages.AddItem{
|
msg := &messages.AddItem{
|
||||||
ItemId: uint32(item.Id),
|
ItemId: uint32(item.Id),
|
||||||
Quantity: int32(qty),
|
Quantity: int32(qty),
|
||||||
Price: price,
|
Price: price,
|
||||||
OrgPrice: orgPriceFromDiscount(price, item.Discount),
|
OrgPrice: orgPriceFromDiscount(price, item.Discount),
|
||||||
Sku: item.Sku,
|
Sku: item.Sku,
|
||||||
Name: item.Title,
|
Name: item.Title,
|
||||||
Image: item.Img,
|
Image: item.Img,
|
||||||
Stock: stock,
|
Stock: stock,
|
||||||
// item.Vat is the product's VAT as a raw integer percent (e.g. 25);
|
// item.Vat is the product's VAT as a raw integer percent (e.g. 25);
|
||||||
// ×100 converts to the platform basis-point scale (2500). This is the
|
// ×100 converts to the platform basis-point scale (2500). This is the
|
||||||
// single conversion boundary — everything downstream is basis points.
|
// single conversion boundary — everything downstream is basis points.
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +16,7 @@ import (
|
|||||||
// (possibly partial) evaluation context and returns the totals plus the
|
// (possibly partial) evaluation context and returns the totals plus the
|
||||||
// applied/pending promotion effects, without creating or mutating a real cart.
|
// applied/pending promotion effects, without creating or mutating a real cart.
|
||||||
// An empty body is treated as an empty context (evaluates against no items).
|
// An empty body is treated as an empty context (evaluates against no items).
|
||||||
|
// Pure stateless — callers compose the line list themselves.
|
||||||
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req promotions.EvaluateRequest
|
var req promotions.EvaluateRequest
|
||||||
@@ -27,3 +31,179 @@ func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.Promot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newPromotionEvaluateWithCartHandler serves POST /promotions/evaluate-with-cart.
|
||||||
|
// Reads the `cartid` cookie the storefront already maintains (set by
|
||||||
|
// GET /cart on first visit), fetches the actual cart from the grain pool
|
||||||
|
// (cross-pod via GetAnywhere so a cart owned by another pod is fetched
|
||||||
|
// transparently), deep-copies the grain, merges the PDP request items
|
||||||
|
// into the copy (replace-by-sku semantic so the PDP qty wins for any
|
||||||
|
// product already in the cart), and runs the canonical
|
||||||
|
// promotions.PromotionService.EvaluateAndApply pipeline the live cart
|
||||||
|
// processor uses — so the preview sees the cart's vouchers, customer
|
||||||
|
// segment, customer-lifetime-value, and computed total, not just the
|
||||||
|
// line list, and the coupon+promotion-overlap edge case is handled
|
||||||
|
// the same way on both sides. Returns the same EvaluateResponse shape
|
||||||
|
// as the stateless endpoint.
|
||||||
|
//
|
||||||
|
// Why this exists: the storefront's "I kundkorgen" preview needs the
|
||||||
|
// engine's verdict for "what would the cart look like if I add this
|
||||||
|
// product". The cart is the source of truth (it has vouchers, customer
|
||||||
|
// segment, customer-lifetime-value, total, etc. that the engine needs),
|
||||||
|
// and a serialised copy sent from the browser can race the live cart.
|
||||||
|
// Letting the backend read the cookie + fetch the grain means there is
|
||||||
|
// exactly one source of truth and no race. The grain is deep-copied via
|
||||||
|
// JSON marshal/unmarshal so the preview never mutates the live state —
|
||||||
|
// every read is purely a what-if computation.
|
||||||
|
//
|
||||||
|
// Missing/invalid cookie (new visitor, preview-only tab): the handler
|
||||||
|
// treats the request as a pure stateless evaluation of the request
|
||||||
|
// items alone — the "what would I get just for the product I'm
|
||||||
|
// viewing" answer is still useful on its own.
|
||||||
|
//
|
||||||
|
// Cart fetch failure (the grain is gone, the pool is degraded, etc.):
|
||||||
|
// the handler logs and falls through to the same stateless evaluation
|
||||||
|
// of the request items. The preview degrades gracefully rather than
|
||||||
|
// erroring the whole block.
|
||||||
|
//
|
||||||
|
// "Replace" merge semantic: if the same sku is in both the cart and
|
||||||
|
// the request items, the request item wins (with its qty). This
|
||||||
|
// matches the PDP's intent ("I am previewing adding this product
|
||||||
|
// at this qty") and avoids double-counting the same product.
|
||||||
|
func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotions.PromotionService, server *PoolServer) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req promotions.EvaluateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp promotions.EvaluateResponse
|
||||||
|
if g := cloneCartForPreview(server, r); g != nil {
|
||||||
|
mergeRequestItemsIntoGrain(g, req.Items, svc.DefaultTaxProvider)
|
||||||
|
// One call to the canonical pipeline — same code path
|
||||||
|
// the live cart processor's reg.RegisterProcessor in
|
||||||
|
// main.go uses. Going through the shared
|
||||||
|
// PromotionService.EvaluateAndApply means the bypass
|
||||||
|
// loop, the re-eval trigger, and ApplyResults stay
|
||||||
|
// in lockstep with post-add behavior. EvaluateAndApply
|
||||||
|
// populates g.EvaluatedItems via cart.MapEvaluatedItems
|
||||||
|
// on its final step, so the preview response reuses the
|
||||||
|
// same per-line projection the live cart renders — math
|
||||||
|
// and rounding stay in lockstep across both paths.
|
||||||
|
svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...)
|
||||||
|
resp = promotions.EvaluateResponse{
|
||||||
|
TotalPrice: g.TotalPrice,
|
||||||
|
TotalDiscount: g.TotalDiscount,
|
||||||
|
AppliedPromotions: g.AppliedPromotions,
|
||||||
|
Items: g.EvaluatedItems,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No live cart (missing/invalid cookie, fetch failed, or
|
||||||
|
// deep-copy failed). Fall back to the pure stateless
|
||||||
|
// evaluation of the request items alone — the user still
|
||||||
|
// gets a useful preview of "what would the engine do for
|
||||||
|
// just the items I'm about to add".
|
||||||
|
resp = svc.Evaluate(store.Snapshot(), req)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneCartForPreview reads the cartid cookie, fetches the live grain
|
||||||
|
// (cross-pod via GetAnywhere), and returns a deep copy safe to mutate
|
||||||
|
// for the what-if preview. Returns nil (and logs) when the cookie is
|
||||||
|
// missing/invalid OR the grain fetch fails OR the deep-copy fails —
|
||||||
|
// the caller falls back to a stateless evaluation of the request items
|
||||||
|
// alone.
|
||||||
|
//
|
||||||
|
// The deep copy is via JSON marshal/unmarshal, which is intentional: it
|
||||||
|
// produces a fully independent *cart.CartGrain (re-allocating every
|
||||||
|
// pointer — Items, Vouchers, ItemMeta, Price, etc.) so the live grain
|
||||||
|
// is never touched, even if the live grain holds non-serializable
|
||||||
|
// unexported state like an in-memory event-log channel. Unexported
|
||||||
|
// fields are silently dropped by the json package, which is exactly
|
||||||
|
// what we want here — the engine only reads the grain's exported
|
||||||
|
// state (Items, Vouchers, TotalPrice, etc.), and the live grain's
|
||||||
|
// in-memory event log / channels must not be cloned or shared.
|
||||||
|
func cloneCartForPreview(server *PoolServer, r *http.Request) *cart.CartGrain {
|
||||||
|
cookie, err := r.Cookie("cartid")
|
||||||
|
if err != nil || cookie.Value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id, ok := cart.ParseCartId(cookie.Value)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: invalid cartid cookie, falling back to stateless")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
live, err := server.GetAnywhere(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: cart fetch failed for %s, falling back to stateless: %v", id, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if live == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(live)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: cart marshal failed for %s, falling back to stateless: %v", id, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var clone *cart.CartGrain
|
||||||
|
if err := json.Unmarshal(buf, &clone); err != nil {
|
||||||
|
log.Printf("promotions/evaluate-with-cart: cart unmarshal failed for %s, falling back to stateless: %v", id, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeRequestItemsIntoGrain merges reqItems into the grain's items with
|
||||||
|
// "replace" semantic: any cart line whose sku also appears in the
|
||||||
|
// request is dropped (the request item wins, so the PDP qty replaces
|
||||||
|
// the cart qty for that product). Remaining cart lines are kept
|
||||||
|
// verbatim, then the converted request items are appended. The grain
|
||||||
|
// is mutated in place — caller is expected to be holding a deep copy
|
||||||
|
// from cloneCartForPreview. The EvalItem→CartItem conversion uses the
|
||||||
|
// shared promotions.EvalItem.ToCartItem helper so the math (VAT rate
|
||||||
|
// rounding, qty default, ItemMeta, Price) matches the synthetic-cart
|
||||||
|
// conversion in the stateless endpoint exactly.
|
||||||
|
func mergeRequestItemsIntoGrain(g *cart.CartGrain, reqItems []promotions.EvalItem, tp cart.TaxProvider) {
|
||||||
|
requestSkus := make(map[string]struct{}, len(reqItems))
|
||||||
|
for _, it := range reqItems {
|
||||||
|
if it.Sku != "" {
|
||||||
|
requestSkus[it.Sku] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged := make([]*cart.CartItem, 0, len(g.Items)+len(reqItems))
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if _, taken := requestSkus[it.Sku]; taken {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged = append(merged, it)
|
||||||
|
}
|
||||||
|
for _, it := range reqItems {
|
||||||
|
merged = append(merged, it.ToCartItem(tp))
|
||||||
|
}
|
||||||
|
g.Items = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// previewContextOptions builds the ContextOption list for the
|
||||||
|
// cart-aware preview. The shared EvaluateRequest.ContextOptions
|
||||||
|
// already includes WithNow when req.Now is set, so we only prepend a
|
||||||
|
// default WithNow(time.Now()) when the request didn't specify one —
|
||||||
|
// avoiding a duplicate WithNow in the opts list (the engine's
|
||||||
|
// NewContextFromCart applies options in order, but the last-one-wins
|
||||||
|
// rule for duplicates is not a contract we want to rely on). The
|
||||||
|
// other context options (CustomerSegment, CLV, OrderCount) come from
|
||||||
|
// req.ContextOptions unchanged.
|
||||||
|
func previewContextOptions(req promotions.EvaluateRequest) []promotions.ContextOption {
|
||||||
|
opts := req.ContextOptions()
|
||||||
|
if req.Now == nil {
|
||||||
|
opts = append([]promotions.ContextOption{promotions.WithNow(time.Now())}, opts...)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,6 +151,33 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflected applied promotions as negative discount lines
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discountVal := int64(0)
|
||||||
|
if ap.Discount != nil {
|
||||||
|
discountVal = ap.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
if discountVal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, &Line{
|
||||||
|
Type: "discount",
|
||||||
|
Reference: "promo-" + ap.PromotionId,
|
||||||
|
Name: "Promotion: " + ap.Name,
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: int(-discountVal),
|
||||||
|
QuantityUnit: "st",
|
||||||
|
TotalAmount: int(-discountVal),
|
||||||
|
TaxRate: 2500,
|
||||||
|
TotalTaxAmount: int(-discountVal * 2500 / 12500),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
order := &CheckoutOrder{
|
order := &CheckoutOrder{
|
||||||
PurchaseCountry: country,
|
PurchaseCountry: country,
|
||||||
PurchaseCurrency: currency,
|
PurchaseCurrency: currency,
|
||||||
@@ -285,6 +312,30 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflected applied promotions as negative discount lines
|
||||||
|
if grain.CartState != nil {
|
||||||
|
for _, ap := range grain.CartState.AppliedPromotions {
|
||||||
|
if ap.Pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discountVal := int64(0)
|
||||||
|
if ap.Discount != nil {
|
||||||
|
discountVal = ap.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
if discountVal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||||
|
Quantity: common.PtrInt64(1),
|
||||||
|
AmountIncludingTax: common.PtrInt64(-discountVal),
|
||||||
|
Description: common.PtrString("Promotion: " + ap.Name),
|
||||||
|
AmountExcludingTax: common.PtrInt64(-discountVal * 10000 / 12500),
|
||||||
|
TaxAmount: common.PtrInt64(-discountVal * 2500 / 12500),
|
||||||
|
TaxPercentage: common.PtrInt64(2500),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
return &adyenCheckout.CreateCheckoutSessionRequest{
|
||||||
Reference: grain.Id.String(),
|
Reference: grain.Id.String(),
|
||||||
Amount: adyenCheckout.Amount{
|
Amount: adyenCheckout.Amount{
|
||||||
|
|||||||
+53
-7
@@ -21,17 +21,16 @@ import (
|
|||||||
"git.k6n.net/mats/platform/tax"
|
"git.k6n.net/mats/platform/tax"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
// checkoutMetrics is the shared prometheus instrumentation for the
|
||||||
Name: "checkout_grain_spawned_total",
|
// checkout actor pool and event log. Built once at process start;
|
||||||
Help: "The total number of spawned checkout grains",
|
// the actor package's touch sites are nil-safe so a test binary
|
||||||
})
|
// could pass nil.
|
||||||
|
checkoutMetrics = actor.NewMetrics("checkout")
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -114,14 +113,15 @@ func main() {
|
|||||||
checkoutDir = "data"
|
checkoutDir = "data"
|
||||||
}
|
}
|
||||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
||||||
|
diskStorage.SetMetrics(checkoutMetrics)
|
||||||
var syncedServer *CheckoutPoolServer
|
var syncedServer *CheckoutPoolServer
|
||||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
|
Metrics: checkoutMetrics,
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
||||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
||||||
defer span.End()
|
defer span.End()
|
||||||
grainSpawns.Inc()
|
|
||||||
|
|
||||||
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
||||||
// Load persisted events/state for this checkout if present
|
// Load persisted events/state for this checkout if present
|
||||||
@@ -212,6 +212,31 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Data Retention (C5) GDPR Purge
|
||||||
|
retentionDays := config.EnvInt("CHECKOUT_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||||
|
if retentionDays > 0 {
|
||||||
|
purgeInterval := config.EnvDuration("CHECKOUT_PURGE_INTERVAL", 24*time.Hour)
|
||||||
|
log.Printf("checkout: data retention enabled. Retaining checkouts for %d days, purging every %v", retentionDays, purgeInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(purgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("checkout: data retention / GDPR purge disabled (CHECKOUT_RETENTION_DAYS not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
@@ -299,3 +324,24 @@ func main() {
|
|||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[checkout.CheckoutGrain], pool *actor.SimpleGrainPool[checkout.CheckoutGrain], retentionDays int) {
|
||||||
|
log.Printf("checkout: starting data retention purge...")
|
||||||
|
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||||
|
|
||||||
|
localIds := make(map[uint64]bool)
|
||||||
|
for _, id := range pool.GetLocalIds() {
|
||||||
|
localIds[id] = true
|
||||||
|
}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return localIds[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("checkout: data retention purge failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("checkout: data retention purge completed. Purged %d expired checkout logs", purged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import (
|
|||||||
"git.k6n.net/mats/platform/inventory"
|
"git.k6n.net/mats/platform/inventory"
|
||||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
|
||||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
@@ -33,16 +31,13 @@ import (
|
|||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// Pool metrics (checkout_grain_spawned_total, checkout_grain_lookups_total,
|
||||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
// checkout_mutations_total, checkout_remote_negotiation_total,
|
||||||
Name: "checkout_grain_mutations_total",
|
// checkout_connected_remotes, …) are bumped centrally by
|
||||||
Help: "The total number of mutations",
|
// SimpleGrainPool — see pkg/actor/simple_grain_pool.go. The checkout
|
||||||
})
|
// Metrics is registered once in cmd/checkout/main.go and shared with
|
||||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
// DiskStorage via SetMetrics, so the per-handler counters this file
|
||||||
Name: "checkout_grain_lookups_total",
|
// used to maintain are no longer needed.
|
||||||
Help: "The total number of lookups",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
type CheckoutPoolServer struct {
|
type CheckoutPoolServer struct {
|
||||||
actor.GrainPool[checkout.CheckoutGrain]
|
actor.GrainPool[checkout.CheckoutGrain]
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http
|
|||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||||
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
||||||
|
|
||||||
grainLookups.Inc()
|
|
||||||
if err == nil && handled {
|
if err == nil && handled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func buildFlowRegistry(
|
|||||||
) *flow.Registry {
|
) *flow.Registry {
|
||||||
freg := flow.NewRegistry()
|
freg := flow.NewRegistry()
|
||||||
flow.RegisterBuiltinHooks(freg)
|
flow.RegisterBuiltinHooks(freg)
|
||||||
order.RegisterFlowActions(freg, applier, provider)
|
order.RegisterFlowActions(freg, applier, provider, prefChecker)
|
||||||
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
|
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
|
||||||
order.RegisterEmitHook(freg, emitPub)
|
order.RegisterEmitHook(freg, emitPub)
|
||||||
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
|
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -222,6 +223,7 @@ func main() {
|
|||||||
mux.HandleFunc("POST /checkout", s.handleCheckout)
|
mux.HandleFunc("POST /checkout", s.handleCheckout)
|
||||||
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
|
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
|
||||||
mux.HandleFunc("GET /api/orders", s.handleList)
|
mux.HandleFunc("GET /api/orders", s.handleList)
|
||||||
|
mux.HandleFunc("GET /api/orders/stats", s.handleStats)
|
||||||
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
||||||
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
||||||
|
|
||||||
@@ -443,6 +445,160 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
|
|||||||
return po
|
return po
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- dashboard stats ------------------------------------------------------
|
||||||
|
|
||||||
|
type orderAlert struct {
|
||||||
|
Severity string `json:"severity"` // warn | error
|
||||||
|
Message string `json:"message"`
|
||||||
|
OrderId string `json:"orderId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusBucket struct {
|
||||||
|
Status order.Status `json:"status"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type revenueSnapshot struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Captured int64 `json:"captured"`
|
||||||
|
Refunded int64 `json:"refunded"`
|
||||||
|
Pending int64 `json:"pending"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyRevenue struct {
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderStatsResponse struct {
|
||||||
|
OrdersTotal int `json:"ordersTotal"`
|
||||||
|
Revenue revenueSnapshot `json:"revenue"`
|
||||||
|
ByStatus []statusBucket `json:"byStatus"`
|
||||||
|
RecentOrders []orderSummary `json:"recentOrders"`
|
||||||
|
DailyRevenue30 []dailyRevenue `json:"dailyRevenue30,omitempty"`
|
||||||
|
Alerts []orderAlert `json:"alerts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStats scans all order logs and returns aggregated dashboard stats.
|
||||||
|
func (s *server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
|
||||||
|
now := time.Now()
|
||||||
|
var totalOrders int
|
||||||
|
var totalRevenue, capturedRevenue, refundedRevenue int64
|
||||||
|
byStatus := map[order.Status]int{}
|
||||||
|
statusTotal := map[order.Status]int64{} // total amount per status
|
||||||
|
|
||||||
|
// Daily revenue for the last 30 days (keys are "2026-06-01" sortable strings).
|
||||||
|
dailyByDay := map[string]int64{}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
dailyByDay[day] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all order summaries, then sort by placedAt and take top 10.
|
||||||
|
var allOrders []orderSummary
|
||||||
|
var alerts []orderAlert
|
||||||
|
|
||||||
|
for _, m := range matches {
|
||||||
|
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
|
||||||
|
raw, err := strconv.ParseUint(base, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g, err := s.pool.Get(r.Context(), raw)
|
||||||
|
if err != nil || g.Status == order.StatusNew {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalOrders++
|
||||||
|
byStatus[g.Status]++
|
||||||
|
statusTotal[g.Status] += g.TotalAmount.Int64()
|
||||||
|
|
||||||
|
totalRevenue += g.TotalAmount.Int64()
|
||||||
|
capturedRevenue += g.CapturedAmount.Int64()
|
||||||
|
refundedRevenue += g.RefundedAmount.Int64()
|
||||||
|
|
||||||
|
// Daily revenue — parse placedAt to extract date.
|
||||||
|
if g.PlacedAt != "" {
|
||||||
|
if placed, parseErr := time.Parse(time.RFC3339, g.PlacedAt); parseErr == nil {
|
||||||
|
dayKey := placed.Format("2006-01-02")
|
||||||
|
if _, ok := dailyByDay[dayKey]; ok {
|
||||||
|
dailyByDay[dayKey] += g.TotalAmount.Int64()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allOrders = append(allOrders, orderSummary{
|
||||||
|
OrderId: order.OrderId(raw).String(),
|
||||||
|
Reference: g.OrderReference,
|
||||||
|
Status: g.Status,
|
||||||
|
TotalAmount: g.TotalAmount.Int64(),
|
||||||
|
Currency: g.Currency,
|
||||||
|
PlacedAt: g.PlacedAt,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Alerts.
|
||||||
|
if g.Status == order.StatusPending {
|
||||||
|
alerts = append(alerts, orderAlert{
|
||||||
|
Severity: "warn",
|
||||||
|
Message: "Payment still pending",
|
||||||
|
OrderId: order.OrderId(raw).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if g.Status == order.StatusCancelled {
|
||||||
|
alerts = append(alerts, orderAlert{
|
||||||
|
Severity: "warn",
|
||||||
|
Message: "Order was cancelled",
|
||||||
|
OrderId: order.OrderId(raw).String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort ALL orders by placedAt descending, take top 10 as "recent".
|
||||||
|
sort.Slice(allOrders, func(i, j int) bool {
|
||||||
|
return allOrders[i].PlacedAt > allOrders[j].PlacedAt
|
||||||
|
})
|
||||||
|
recent := allOrders
|
||||||
|
if len(recent) > 10 {
|
||||||
|
recent = recent[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap alerts
|
||||||
|
if len(alerts) > 50 {
|
||||||
|
alerts = alerts[:50]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build status buckets array with totals.
|
||||||
|
buckets := make([]statusBucket, 0, len(byStatus))
|
||||||
|
for st, cnt := range byStatus {
|
||||||
|
buckets = append(buckets, statusBucket{Status: st, Count: cnt, Total: statusTotal[st]})
|
||||||
|
}
|
||||||
|
sort.Slice(buckets, func(i, j int) bool {
|
||||||
|
return buckets[i].Count > buckets[j].Count
|
||||||
|
})
|
||||||
|
|
||||||
|
// Daily revenue as an ordered slice.
|
||||||
|
var dailyRev []dailyRevenue
|
||||||
|
for i := 29; i >= 0; i-- {
|
||||||
|
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||||
|
dailyRev = append(dailyRev, dailyRevenue{Date: day, Total: dailyByDay[day]})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, orderStatsResponse{
|
||||||
|
OrdersTotal: totalOrders,
|
||||||
|
Revenue: revenueSnapshot{
|
||||||
|
Total: totalRevenue,
|
||||||
|
Captured: capturedRevenue,
|
||||||
|
Refunded: refundedRevenue,
|
||||||
|
Pending: totalRevenue - capturedRevenue - refundedRevenue,
|
||||||
|
},
|
||||||
|
ByStatus: buckets,
|
||||||
|
RecentOrders: recent,
|
||||||
|
DailyRevenue30: dailyRev,
|
||||||
|
Alerts: alerts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// --- reads ----------------------------------------------------------------
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// clusterAwareApplier is a ProfileApplier that chooses between an
|
||||||
|
// in-process pool call and a remote forward based on which replica
|
||||||
|
// currently owns the grain. It is the single seam at which the auth
|
||||||
|
// server (`customerauth.Server`) and the UCP customer handler
|
||||||
|
// (`ucp.CustomerServer`) ever speak to the grain pool — keeping the
|
||||||
|
// decision of "go to the authoritative owner" vs "spawn locally" in
|
||||||
|
// one place avoids the split-brain hazard introduced when multiple
|
||||||
|
// code paths (HTTP middleware + the handlers' own pool.Get) could each
|
||||||
|
// independently decide to spawn a stale or empty grain and broadcast
|
||||||
|
// conflicting ownership for it.
|
||||||
|
//
|
||||||
|
// The decision rule:
|
||||||
|
//
|
||||||
|
// - pool.OwnerHost(id) returns a remote host → forward Get/Apply
|
||||||
|
// to that host. The host holds the authoritative in-memory state.
|
||||||
|
// - pool.OwnerHost(id) returns no host → delegate to
|
||||||
|
// pool.Get / pool.Apply. On a local cache miss pool.Get spawns
|
||||||
|
// the grain from disk on this pod, broadcasts the new ownership,
|
||||||
|
// and returns the grain; this is the only code path that ever
|
||||||
|
// claims a grain on this pod.
|
||||||
|
//
|
||||||
|
// pool.Get's spawn path is unchanged on purpose: the disk-backed event
|
||||||
|
// log is the source of truth, and the local cache is just a projection
|
||||||
|
// rebuilt from it. The risk surface that the applicr layer removes is
|
||||||
|
// the HTTP-middleware fall-through that re-entered the same pool.Get
|
||||||
|
// from a different code path and duplicated the decision.
|
||||||
|
type clusterAwareApplier struct {
|
||||||
|
pool *actor.SimpleGrainPool[profile.ProfileGrain]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the current state of grain id, preferring the
|
||||||
|
// authoritative remote owner when one is registered. With no remote
|
||||||
|
// owner the pool spawns the grain locally (from the disk-backed event
|
||||||
|
// log), takes ownership, and returns the grain.
|
||||||
|
func (a *clusterAwareApplier) Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error) {
|
||||||
|
if owner, ok := a.pool.OwnerHost(id); ok {
|
||||||
|
return owner.Get(ctx, id)
|
||||||
|
}
|
||||||
|
return a.pool.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply mutates grain id with messages, forwarding to the authoritative
|
||||||
|
// remote owner when one is registered. With no remote owner the pool
|
||||||
|
// spawns the grain locally, applies the mutation, takes ownership, and
|
||||||
|
// returns the mutated state — its listeners (including the AMQP
|
||||||
|
// mutation feed) fire from this pod.
|
||||||
|
func (a *clusterAwareApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
|
||||||
|
if owner, ok := a.pool.OwnerHost(id); ok {
|
||||||
|
return owner.Apply(ctx, id, msgs...)
|
||||||
|
}
|
||||||
|
return a.pool.Apply(ctx, id, msgs...)
|
||||||
|
}
|
||||||
+75
-4
@@ -98,6 +98,13 @@ func authSecret() []byte {
|
|||||||
var podIp = os.Getenv("POD_IP")
|
var podIp = os.Getenv("POD_IP")
|
||||||
var name = os.Getenv("POD_NAME")
|
var name = os.Getenv("POD_NAME")
|
||||||
|
|
||||||
|
// profileMetrics is the shared prometheus instrumentation for the
|
||||||
|
// profile actor pool and event log. Built once at process start; the
|
||||||
|
// actor package's touch sites are nil-safe so a test binary could pass
|
||||||
|
// nil. Package-level so tests in this package can construct a pool
|
||||||
|
// without re-registering (cart/checkout use the same pattern).
|
||||||
|
var profileMetrics = actor.NewMetrics("profile")
|
||||||
|
|
||||||
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
|
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
|
||||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||||
func loadUCPProfileSigner() *ucp.SigningConfig {
|
func loadUCPProfileSigner() *ucp.SigningConfig {
|
||||||
@@ -126,10 +133,12 @@ func main() {
|
|||||||
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
|
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
|
||||||
}
|
}
|
||||||
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
|
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
|
||||||
|
diskStorage.SetMetrics(profileMetrics)
|
||||||
|
|
||||||
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
|
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
|
Metrics: profileMetrics,
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
|
||||||
ret := profile.NewProfileGrain(id, time.Now())
|
ret := profile.NewProfileGrain(id, time.Now())
|
||||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||||
@@ -187,6 +196,31 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
|
// Data Retention (C5) GDPR Purge
|
||||||
|
retentionDays := config.EnvInt("PROFILE_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||||
|
if retentionDays > 0 {
|
||||||
|
purgeInterval := config.EnvDuration("PROFILE_PURGE_INTERVAL", 24*time.Hour)
|
||||||
|
log.Printf("profile: data retention enabled. Retaining profiles for %d days, purging every %v", retentionDays, purgeInterval)
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(purgeInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run immediately on startup
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Print("profile: data retention / GDPR purge disabled (PROFILE_RETENTION_DAYS not set or <= 0)")
|
||||||
|
}
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
@@ -209,12 +243,28 @@ func main() {
|
|||||||
|
|
||||||
// UCP Customer REST adapter
|
// UCP Customer REST adapter
|
||||||
auditLogPath := filepath.Join(profileDir, "audit.log")
|
auditLogPath := filepath.Join(profileDir, "audit.log")
|
||||||
customerUCP := ucp.CustomerHandler(pool, auditLogPath,
|
// Session signer is shared by the auth server (HMAC verifier for the
|
||||||
|
// "sid" cookie); NewSigner is stateless once built so a single
|
||||||
|
// instance can be reused across the auth server's NewServer call.
|
||||||
|
sessionSigner := customerauth.NewSigner(authSecret())
|
||||||
|
|
||||||
|
// Cluster-aware ProfileApplier: routes Get/Apply to the replica that
|
||||||
|
// currently owns the grain. With a remote owner we forward there so
|
||||||
|
// the request reads the authoritative in-memory state; with no owner
|
||||||
|
// we let pool.Get / pool.Apply spawn the grain locally from the
|
||||||
|
// disk-backed event log and broadcast ownership. Centralising this
|
||||||
|
// choice in the applicr (rather than HTTP middleware) prevents the
|
||||||
|
// split-brain hazard where a non-owner pod reads a stale or empty
|
||||||
|
// grain from disk and caches it under its own (conflicting)
|
||||||
|
// ownership.
|
||||||
|
applier := &clusterAwareApplier{pool: pool}
|
||||||
|
|
||||||
|
customerUCP := ucp.CustomerHandler(applier, auditLogPath,
|
||||||
ucp.WithEmailIndex(emailIndex),
|
ucp.WithEmailIndex(emailIndex),
|
||||||
ucp.WithCredentialDeleter(credStore),
|
ucp.WithCredentialDeleter(credStore),
|
||||||
)
|
)
|
||||||
if signer := loadUCPProfileSigner(); signer != nil {
|
if ucpSigner := loadUCPProfileSigner(); ucpSigner != nil {
|
||||||
customerUCP = ucp.WithSigning(customerUCP, signer)
|
customerUCP = ucp.WithSigning(customerUCP, ucpSigner)
|
||||||
log.Print("ucp customer signing enabled")
|
log.Print("ucp customer signing enabled")
|
||||||
}
|
}
|
||||||
// StripPrefix is required because the sub-mux (CustomerHandler) registers
|
// StripPrefix is required because the sub-mux (CustomerHandler) registers
|
||||||
@@ -224,7 +274,7 @@ func main() {
|
|||||||
|
|
||||||
// Customer auth: password signup/login + session cookies + identity linking.
|
// Customer auth: password signup/login + session cookies + identity linking.
|
||||||
authNotifier := selectAuthNotifier()
|
authNotifier := selectAuthNotifier()
|
||||||
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
|
authHandler := customerauth.NewServer(credStore, applier, sessionSigner, 0, customerauth.Options{
|
||||||
Notifier: authNotifier,
|
Notifier: authNotifier,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
|
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
|
||||||
@@ -292,3 +342,24 @@ func main() {
|
|||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[profile.ProfileGrain], pool *actor.SimpleGrainPool[profile.ProfileGrain], retentionDays int) {
|
||||||
|
log.Printf("profile: starting data retention purge...")
|
||||||
|
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||||
|
|
||||||
|
localIds := make(map[uint64]bool)
|
||||||
|
for _, id := range pool.GetLocalIds() {
|
||||||
|
localIds[id] = true
|
||||||
|
}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return localIds[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("profile: data retention purge failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("profile: data retention purge completed. Purged %d expired profile logs", purged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-3
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"id": "volymrabatt",
|
"id": "volymrabatt",
|
||||||
"name": "Volymrabatt",
|
"name": "Volymrabatt",
|
||||||
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 000–40 000 kr → 5%, 40 000–60 000 kr → 9%, 60 000–90 000 kr → 14%.",
|
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). 20 000+ kr → 5%, 40 000+ kr → 9%, 60 000+ kr → 14%.",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
"startDate": "2024-01-01",
|
"startDate": "2024-01-01",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"tiers": [
|
"tiers": [
|
||||||
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
|
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
|
||||||
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
|
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
|
||||||
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
|
{ "minTotal": 6000000, "maxTotal": 0, "percent": 14 }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"label": "Volymrabatt 5–14%"
|
"label": "Volymrabatt 5–14%"
|
||||||
@@ -38,7 +38,106 @@
|
|||||||
"createdAt": "2024-01-01T00:00:00Z",
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
"updatedAt": "2024-01-01T00:00:00Z",
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
"createdBy": "mats.tornberg@gmail.com",
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
"tags": ["volume", "volymrabatt", "test"]
|
"tags": ["volume", "volymrabatt"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fri-frakt",
|
||||||
|
"name": "Fri frakt över 10 000 kr",
|
||||||
|
"description": "Fri frakt när varukorgens totalsumma (inkl. moms) överstiger 10 000 kr.",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 50,
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "min-cart-total",
|
||||||
|
"type": "cart_total",
|
||||||
|
"operator": ">=",
|
||||||
|
"value": 1000000,
|
||||||
|
"label": "Minst 10 000 kr i varukorgen"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "fri-frakt-action",
|
||||||
|
"type": "free_shipping",
|
||||||
|
"value": 0,
|
||||||
|
"label": "Fri frakt"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
|
"tags": ["shipping", "free-shipping"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fonster-10-pct",
|
||||||
|
"name": "10% rabatt på fönster",
|
||||||
|
"description": "10% rabatt på alla fönster i varukorgen.",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 200,
|
||||||
|
"startDate": "2024-01-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cat-windows",
|
||||||
|
"type": "product_category",
|
||||||
|
"operator": "in",
|
||||||
|
"value": ["Fönster"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "fonster-pct",
|
||||||
|
"type": "percentage_discount",
|
||||||
|
"value": 10,
|
||||||
|
"label": "10% rabatt på fönster"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00Z",
|
||||||
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
|
"tags": ["windows", "category"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sommar15",
|
||||||
|
"name": "15% rabatt med kod SOMMAR15",
|
||||||
|
"description": "15% rabatt på hela köpet när rabattkoden SOMMAR15 används. Gäller vid köp över 5 000 kr under sommaren.",
|
||||||
|
"status": "active",
|
||||||
|
"priority": 150,
|
||||||
|
"startDate": "2024-06-01",
|
||||||
|
"endDate": null,
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "min-cart-total",
|
||||||
|
"type": "cart_total",
|
||||||
|
"operator": ">=",
|
||||||
|
"value": 500000,
|
||||||
|
"label": "Minst 5 000 kr i varukorgen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "coupon-code",
|
||||||
|
"type": "coupon_code",
|
||||||
|
"operator": "=",
|
||||||
|
"value": "SOMMAR15",
|
||||||
|
"label": "Ange koden SOMMAR15"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "sommar15-pct",
|
||||||
|
"type": "percentage_discount",
|
||||||
|
"value": 15,
|
||||||
|
"label": "15% rabatt med kod"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usageCount": 0,
|
||||||
|
"createdAt": "2024-06-01T00:00:00Z",
|
||||||
|
"updatedAt": "2024-06-01T00:00:00Z",
|
||||||
|
"createdBy": "mats.tornberg@gmail.com",
|
||||||
|
"tags": ["seasonal", "coupon", "summer"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: CART_DIR
|
- name: CART_DIR
|
||||||
value: "/data/cart-actor"
|
value: "/data/cart-actor"
|
||||||
|
- name: PROMOTIONS_FILE
|
||||||
|
value: "/data/cart-actor/promotions.json"
|
||||||
- name: CHECKOUT_DIR
|
- name: CHECKOUT_DIR
|
||||||
value: "/data/checkout-actor"
|
value: "/data/checkout-actor"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
@@ -186,6 +188,8 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: CART_DIR
|
- name: CART_DIR
|
||||||
value: "/data/cart-actor"
|
value: "/data/cart-actor"
|
||||||
|
- name: PROMOTIONS_FILE
|
||||||
|
value: "/data/promotions.json"
|
||||||
- name: CHECKOUT_DIR
|
- name: CHECKOUT_DIR
|
||||||
value: "/data/checkout-actor"
|
value: "/data/checkout-actor"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
@@ -230,6 +234,10 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
|
- name: CART_RETENTION_DAYS
|
||||||
|
value: "30"
|
||||||
|
- name: CART_PURGE_INTERVAL
|
||||||
|
value: "24h"
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
@@ -308,6 +316,8 @@ spec:
|
|||||||
memory: "70Mi"
|
memory: "70Mi"
|
||||||
cpu: "1200m"
|
cpu: "1200m"
|
||||||
env:
|
env:
|
||||||
|
- name: PROMOTIONS_FILE
|
||||||
|
value: "/data/promotions.json"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
- name: REDIS_ADDRESS
|
- name: REDIS_ADDRESS
|
||||||
@@ -350,6 +360,10 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
|
- name: CART_RETENTION_DAYS
|
||||||
|
value: "30"
|
||||||
|
- name: CART_PURGE_INTERVAL
|
||||||
|
value: "24h"
|
||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
@@ -484,6 +498,10 @@ spec:
|
|||||||
valueFrom:
|
valueFrom:
|
||||||
fieldRef:
|
fieldRef:
|
||||||
fieldPath: metadata.name
|
fieldPath: metadata.name
|
||||||
|
- name: CHECKOUT_RETENTION_DAYS
|
||||||
|
value: "30"
|
||||||
|
- name: CHECKOUT_PURGE_INTERVAL
|
||||||
|
value: "24h"
|
||||||
---
|
---
|
||||||
kind: Service
|
kind: Service
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
||||||
"datasource": "${DS_PROMETHEUS}",
|
"datasource": "${DS_PROMETHEUS}",
|
||||||
"targets": [
|
"targets": [
|
||||||
{ "refId": "A", "expr": "connected_remotes" }
|
{ "refId": "A", "expr": "cart_connected_remotes" }
|
||||||
],
|
],
|
||||||
"options": {
|
"options": {
|
||||||
"colorMode": "value",
|
"colorMode": "value",
|
||||||
|
|||||||
@@ -163,6 +163,13 @@ type resetCompleteRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmailPreferencesResponse mirrors the UCP email preferences type for the
|
||||||
|
// customer-facing API.
|
||||||
|
type EmailPreferencesResponse struct {
|
||||||
|
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||||
|
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
||||||
// import cycle with internal/ucp). The password hash is never included.
|
// import cycle with internal/ucp). The password hash is never included.
|
||||||
type CustomerResponse struct {
|
type CustomerResponse struct {
|
||||||
@@ -176,6 +183,9 @@ type CustomerResponse struct {
|
|||||||
Addresses []AddressResponse `json:"addresses"`
|
Addresses []AddressResponse `json:"addresses"`
|
||||||
Orders []OrderRef `json:"orders"`
|
Orders []OrderRef `json:"orders"`
|
||||||
|
|
||||||
|
// EmailPreferences carries the customer's current email opt-in/out state.
|
||||||
|
EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"`
|
||||||
|
|
||||||
// EmailVerified reflects whether the email-verification flow has completed.
|
// EmailVerified reflects whether the email-verification flow has completed.
|
||||||
EmailVerified bool `json:"emailVerified"`
|
EmailVerified bool `json:"emailVerified"`
|
||||||
}
|
}
|
||||||
@@ -554,6 +564,12 @@ func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
|||||||
for _, o := range g.Orders {
|
for _, o := range g.Orders {
|
||||||
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
|
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
|
||||||
}
|
}
|
||||||
|
if g.EmailPreferences != nil {
|
||||||
|
resp.EmailPreferences = &EmailPreferencesResponse{
|
||||||
|
OrderEmails: g.EmailPreferences.OrderEmails,
|
||||||
|
MarketingEmails: g.EmailPreferences.MarketingEmails,
|
||||||
|
}
|
||||||
|
}
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ func (s *CredentialStore) persistLocked() error {
|
|||||||
return fmt.Errorf("customerauth: temp file: %w", err)
|
return fmt.Errorf("customerauth: temp file: %w", err)
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
|
if err := tmp.Chmod(0644); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
os.Remove(tmpName)
|
||||||
|
return fmt.Errorf("customerauth: chmod temp: %w", err)
|
||||||
|
}
|
||||||
if _, err := tmp.Write(data); err != nil {
|
if _, err := tmp.Write(data); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
os.Remove(tmpName)
|
os.Remove(tmpName)
|
||||||
|
|||||||
+15
-4
@@ -185,15 +185,26 @@ func parseCartID(r *http.Request) (uint64, bool) {
|
|||||||
|
|
||||||
func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
|
func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
|
||||||
resp := CartResponse{
|
resp := CartResponse{
|
||||||
Id: id,
|
Id: id,
|
||||||
Items: make([]CartItem, 0, len(g.Items)),
|
Items: make([]CartItem, 0, len(g.Items)),
|
||||||
Totals: Totals{Currency: g.Currency},
|
Totals: Totals{Currency: g.Currency},
|
||||||
Status: "active",
|
Status: "active",
|
||||||
|
AppliedPromotions: g.AppliedPromotions,
|
||||||
}
|
}
|
||||||
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
|
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
|
||||||
resp.Status = string(*g.CheckoutStatus)
|
resp.Status = string(*g.CheckoutStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-line evaluation breakdown. The canonical promotion pipeline
|
||||||
|
// (pkg/promotions.PromotionService.EvaluateAndApply) populates this
|
||||||
|
// field on the grain itself after every successful mutation, so the
|
||||||
|
// conversion just reads g.EvaluatedItems verbatim — no recompute at
|
||||||
|
// response time. Same shape /promotions/evaluate-with-cart returns
|
||||||
|
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems),
|
||||||
|
// so a line verified via GET /ucp/v1/carts/{id} byte-matches the
|
||||||
|
// preview from /promotions/evaluate-with-cart.
|
||||||
|
resp.EvaluatedItems = g.EvaluatedItems
|
||||||
|
|
||||||
for _, it := range g.Items {
|
for _, it := range g.Items {
|
||||||
if it == nil {
|
if it == nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -229,6 +229,106 @@ func TestCartResponse_Conversion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCartResponse_EvaluatedItems verifies that the per-line breakdown the
|
||||||
|
// canonical promotion pipeline populates on CartGrain.EvaluatedItems flows
|
||||||
|
// verbatim into the UCP CartResponse's EvaluatedItems field. Conversion is a
|
||||||
|
// straight pass-through now — we hand-construct the grain's EvaluatedItems
|
||||||
|
// field with the expected values to lock down the wire shape independent of
|
||||||
|
// the math MapEvaluatedItems applies (which has its own tests in pkg/cart).
|
||||||
|
func TestCartResponse_EvaluatedItems(t *testing.T) {
|
||||||
|
id := mustParseID("ABCD")
|
||||||
|
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||||
|
g.Currency = "SEK"
|
||||||
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: *mustPrice(10000, 2500),
|
||||||
|
TotalPrice: *mustPrice(20000, 5000),
|
||||||
|
Tax: 2500,
|
||||||
|
OrgPrice: mustPrice(12000, 3000),
|
||||||
|
Discount: mustPrice(2000, 500),
|
||||||
|
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||||
|
})
|
||||||
|
g.TotalPrice = mustPrice(20000, 5000)
|
||||||
|
|
||||||
|
// In the live cart, pkg/promotions.PromotionService.EvaluateAndApply
|
||||||
|
// populates g.EvaluatedItems via cart.MapEvaluatedItems after every
|
||||||
|
// successful mutation. The conversion test only cares that whatever
|
||||||
|
// the grain carries projects into resp.EvaluatedItems unchanged — so
|
||||||
|
// the input is the canonical shape, not the math's output.
|
||||||
|
g.EvaluatedItems = []cart.EvaluatedItem{{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Name: "Promo Item",
|
||||||
|
Quantity: 2,
|
||||||
|
PriceIncVat: 10000,
|
||||||
|
OrgPriceIncVat: 12000,
|
||||||
|
DiscountIncVat: 2000,
|
||||||
|
EffectivePriceIncVat: 9000,
|
||||||
|
EffectiveTotalIncVat: 18000,
|
||||||
|
}}
|
||||||
|
|
||||||
|
resp := cartGrainToResponse(g.Id.String(), g)
|
||||||
|
if len(resp.EvaluatedItems) != 1 {
|
||||||
|
t.Fatalf("expected 1 evaluated item, got %d", len(resp.EvaluatedItems))
|
||||||
|
}
|
||||||
|
ev := resp.EvaluatedItems[0]
|
||||||
|
if ev.Sku != "promo-1" {
|
||||||
|
t.Fatalf("expected sku 'promo-1', got %q", ev.Sku)
|
||||||
|
}
|
||||||
|
if ev.Name != "Promo Item" {
|
||||||
|
t.Fatalf("expected name 'Promo Item', got %q", ev.Name)
|
||||||
|
}
|
||||||
|
if ev.Quantity != 2 {
|
||||||
|
t.Fatalf("expected qty 2, got %d", ev.Quantity)
|
||||||
|
}
|
||||||
|
if ev.PriceIncVat != 10000 {
|
||||||
|
t.Fatalf("expected priceIncVat 10000, got %d", ev.PriceIncVat)
|
||||||
|
}
|
||||||
|
if ev.OrgPriceIncVat != 12000 {
|
||||||
|
t.Fatalf("expected orgPriceIncVat 12000, got %d", ev.OrgPriceIncVat)
|
||||||
|
}
|
||||||
|
if ev.DiscountIncVat != 2000 {
|
||||||
|
t.Fatalf("expected discountIncVat 2000, got %d", ev.DiscountIncVat)
|
||||||
|
}
|
||||||
|
if ev.EffectiveTotalIncVat != 18000 {
|
||||||
|
t.Fatalf("expected effectiveTotalIncVat 18000, got %d", ev.EffectiveTotalIncVat)
|
||||||
|
}
|
||||||
|
if ev.EffectivePriceIncVat != 9000 {
|
||||||
|
t.Fatalf("expected effectivePriceIncVat 9000, got %d", ev.EffectivePriceIncVat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCartResponse_EvaluatedItems_MapEquivalence confirms that
|
||||||
|
// cart.MapEvaluatedItems produces the same shape the test's hand-built
|
||||||
|
// []cart.EvaluatedItem entries contain, given matching inputs. This locks
|
||||||
|
// the conversion path's output to the canonical pipeline's output — if
|
||||||
|
// either side starts omitting or renaming a field, this fails.
|
||||||
|
func TestCartResponse_EvaluatedItems_MapEquivalence(t *testing.T) {
|
||||||
|
id := mustParseID("ABCD")
|
||||||
|
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||||
|
g.Currency = "SEK"
|
||||||
|
g.Items = append(g.Items, &cart.CartItem{
|
||||||
|
Sku: "promo-1",
|
||||||
|
Quantity: 2,
|
||||||
|
Price: *mustPrice(10000, 2500),
|
||||||
|
TotalPrice: *mustPrice(20000, 5000),
|
||||||
|
Tax: 2500,
|
||||||
|
OrgPrice: mustPrice(12000, 3000),
|
||||||
|
Discount: mustPrice(2000, 500),
|
||||||
|
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||||
|
})
|
||||||
|
g.TotalPrice = mustPrice(20000, 5000)
|
||||||
|
|
||||||
|
canonical := cart.MapEvaluatedItems(g)
|
||||||
|
if len(canonical) != 1 {
|
||||||
|
t.Fatalf("expected 1 evaluated item from MapEvaluatedItems, got %d", len(canonical))
|
||||||
|
}
|
||||||
|
e := canonical[0]
|
||||||
|
if e.DiscountIncVat != 2000 || e.EffectiveTotalIncVat != 18000 || e.EffectivePriceIncVat != 9000 {
|
||||||
|
t.Fatalf("MapEvaluatedItems math off: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
||||||
return &cart.Price{
|
return &cart.Price{
|
||||||
IncVat: money.Cents(incVat),
|
IncVat: money.Cents(incVat),
|
||||||
|
|||||||
@@ -542,6 +542,16 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g
|
|||||||
// buildOrderLines converts a checkout grain's cart items and delivery selections
|
// buildOrderLines converts a checkout grain's cart items and delivery selections
|
||||||
// into order lines, matching the format expected by the order service.
|
// into order lines, matching the format expected by the order service.
|
||||||
func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
||||||
|
hasFreeShipping := false
|
||||||
|
if g.CartState != nil {
|
||||||
|
for _, ap := range g.CartState.AppliedPromotions {
|
||||||
|
if ap.Type == "free_shipping" && !ap.Pending {
|
||||||
|
hasFreeShipping = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
|
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
|
||||||
for _, it := range g.CartState.Items {
|
for _, it := range g.CartState.Items {
|
||||||
if it == nil {
|
if it == nil {
|
||||||
@@ -569,7 +579,14 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, d := range g.Deliveries {
|
for _, d := range g.Deliveries {
|
||||||
if d == nil || d.Price.IncVat <= 0 {
|
if d == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unitPrice := d.Price.IncVat
|
||||||
|
if hasFreeShipping {
|
||||||
|
unitPrice = 0
|
||||||
|
}
|
||||||
|
if unitPrice <= 0 && !hasFreeShipping {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lines = append(lines, orderLine{
|
lines = append(lines, orderLine{
|
||||||
@@ -577,10 +594,35 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
|||||||
Sku: d.Provider,
|
Sku: d.Provider,
|
||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: d.Price.IncVat,
|
UnitPrice: unitPrice,
|
||||||
TaxRate: 2500, // 25% in basis points
|
TaxRate: 2500, // 25% in basis points
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflected applied promotions as negative discount lines
|
||||||
|
if g.CartState != nil {
|
||||||
|
for _, ap := range g.CartState.AppliedPromotions {
|
||||||
|
if ap.Pending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
discountVal := int64(0)
|
||||||
|
if ap.Discount != nil {
|
||||||
|
discountVal = ap.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
if discountVal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines = append(lines, orderLine{
|
||||||
|
Reference: "promo-" + ap.PromotionId,
|
||||||
|
Sku: "promo-" + ap.PromotionId,
|
||||||
|
Name: "Promotion: " + ap.Name,
|
||||||
|
Quantity: 1,
|
||||||
|
UnitPrice: money.Cents(-discountVal),
|
||||||
|
TaxRate: 2500, // default standard tax rate (25%) in basis points
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -121,11 +121,27 @@ type VoucherInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CartResponse is the UCP cart response body.
|
// CartResponse is the UCP cart response body.
|
||||||
|
//
|
||||||
|
// EvaluatedItems mirrors the per-line breakdown the canonical promotion
|
||||||
|
// pipeline (pkg/promotions.PromotionService.EvaluateAndApply) populates
|
||||||
|
// on the grain after every successful mutation — see
|
||||||
|
// pkg/cart/evaluated_item.go for the EvaluatedItem type and the rounding
|
||||||
|
// rules. The shape and field order match the live cart grain's own
|
||||||
|
// EvaluatedItems field and the response from /promotions/evaluate-with-cart
|
||||||
|
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), so a
|
||||||
|
// line verified via GET /ucp/v1/carts/{id} byte-matches the preview.
|
||||||
|
//
|
||||||
|
// Kept parallel to Items rather than merged into CartItem to preserve
|
||||||
|
// the UCP wire contract — existing UCP clients keep working unchanged
|
||||||
|
// and the per-line breakdown can grow on the EvaluatedItem type without
|
||||||
|
// churning the standard item shape.
|
||||||
type CartResponse struct {
|
type CartResponse struct {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
Items []CartItem `json:"items,omitempty"`
|
Items []CartItem `json:"items,omitempty"`
|
||||||
Totals Totals `json:"totals"`
|
EvaluatedItems []cart.EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||||
Status string `json:"status"`
|
Totals Totals `json:"totals"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CartItem is the UCP wire format for a cart line item in responses.
|
// CartItem is the UCP wire format for a cart line item in responses.
|
||||||
|
|||||||
+205
-6
@@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -55,9 +56,57 @@ func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[
|
|||||||
writerIdleTTL: defaultWriterIdleTTL,
|
writerIdleTTL: defaultWriterIdleTTL,
|
||||||
}
|
}
|
||||||
go s.purgeLoop()
|
go s.purgeLoop()
|
||||||
|
go s.filesExistingLoop()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filesExistingLoop refreshes the EventLogFilesExisting gauge every
|
||||||
|
// 30 seconds. The gauge represents the count of .events.log files on
|
||||||
|
// disk, which diverges from the open-writer count whenever the idle
|
||||||
|
// purge closes a writer but the file is still kept (the .log file is
|
||||||
|
// not deleted by the idle purge). Periodic re-listing is cheap (one
|
||||||
|
// os.ReadDir per tick) and is the only way the gauge can pick up files
|
||||||
|
// created or removed out of band (e.g. GDPR purge, or the init-time
|
||||||
|
// rebuild of a grain that already has a log).
|
||||||
|
func (s *DiskStorage[V]) filesExistingLoop() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.refreshFilesExisting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshFilesExisting counts .events.log files in the storage dir
|
||||||
|
// and writes the value to the EventLogFilesExisting gauge. Safe to
|
||||||
|
// call concurrently — os.ReadDir is atomic for our purposes (the dir
|
||||||
|
// is owned by this pod, mutations go through the writersMu lock).
|
||||||
|
// No-op when the dir does not exist yet (cold start) or when metrics
|
||||||
|
// are disabled (s.StateStorage.metrics == nil).
|
||||||
|
func (s *DiskStorage[V]) refreshFilesExisting() {
|
||||||
|
if s.StateStorage.metrics == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(s.path)
|
||||||
|
if err != nil {
|
||||||
|
// Dir may not exist yet on a cold start, or briefly during a
|
||||||
|
// retention-driven directory recreation. Leave the gauge
|
||||||
|
// untouched in that case — the next tick will catch up.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".events.log") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.StateStorage.metrics.EventLogFilesExisting.Set(float64(count))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) ensureDir() error {
|
func (s *DiskStorage[V]) ensureDir() error {
|
||||||
s.dirOnce.Do(func() {
|
s.dirOnce.Do(func() {
|
||||||
s.dirErr = os.MkdirAll(s.path, 0o755)
|
s.dirErr = os.MkdirAll(s.path, 0o755)
|
||||||
@@ -69,6 +118,27 @@ func (s *DiskStorage[V]) ensureDir() error {
|
|||||||
// when finished writing.
|
// when finished writing.
|
||||||
|
|
||||||
|
|
||||||
|
// SetMetrics attaches a Metrics instance to the embedded StateStorage
|
||||||
|
// so every append / replay / unknown-type path has access to the
|
||||||
|
// same event-log counters. The metrics struct is shared by reference;
|
||||||
|
// do not mutate its fields from outside. Pass nil to disable
|
||||||
|
// instrumentation (used by tests that do not care about Prometheus).
|
||||||
|
func (s *DiskStorage[V]) SetMetrics(m *Metrics) {
|
||||||
|
s.StateStorage.metrics = m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) recordAppend(n int) {
|
||||||
|
m := s.StateStorage.metrics
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.EventLogAppends.Inc()
|
||||||
|
if n > 0 {
|
||||||
|
m.EventLogBytesWritten.Add(float64(n))
|
||||||
|
}
|
||||||
|
m.EventLogLastAppendUnix.Set(float64(time.Now().Unix()))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) writeEvents(id uint64, events []QueueEvent) error {
|
func (s *DiskStorage[V]) writeEvents(id uint64, events []QueueEvent) error {
|
||||||
if len(events) == 0 {
|
if len(events) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -80,9 +150,15 @@ func (s *DiskStorage[V]) writeEvents(id uint64, events []QueueEvent) error {
|
|||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
for _, evt := range events {
|
for _, evt := range events {
|
||||||
if err := s.Append(w.file, evt.Message, evt.TimeStamp); err != nil {
|
n, err := s.Append(w.file, evt.Message, evt.TimeStamp)
|
||||||
|
if err != nil {
|
||||||
|
// A partial write still counts the bytes that DID land on
|
||||||
|
// disk, so the throughput gauge is honest about a mid-write
|
||||||
|
// failure.
|
||||||
|
s.recordAppend(n)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
s.recordAppend(n)
|
||||||
}
|
}
|
||||||
w.lastUsed = time.Now()
|
w.lastUsed = time.Now()
|
||||||
return nil
|
return nil
|
||||||
@@ -100,9 +176,12 @@ func (s *DiskStorage[V]) writeMutations(id uint64, msgs ...proto.Message) error
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, m := range msgs {
|
for _, m := range msgs {
|
||||||
if err := s.Append(w.file, m, now); err != nil {
|
n, err := s.Append(w.file, m, now)
|
||||||
|
if err != nil {
|
||||||
|
s.recordAppend(n)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
s.recordAppend(n)
|
||||||
}
|
}
|
||||||
w.lastUsed = time.Now()
|
w.lastUsed = time.Now()
|
||||||
return nil
|
return nil
|
||||||
@@ -201,6 +280,9 @@ func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) save() {
|
func (s *DiskStorage[V]) save() {
|
||||||
|
if s.queue == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
carts := 0
|
carts := 0
|
||||||
lines := 0
|
lines := 0
|
||||||
s.queue.Range(func(key, value any) bool {
|
s.queue.Range(func(key, value any) bool {
|
||||||
@@ -228,6 +310,43 @@ func (s *DiskStorage[V]) logPath(id uint64) string {
|
|||||||
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadReplayMetrics times the replay, records duration, and tracks
|
||||||
|
// handler errors. It is shared by LoadEvents and LoadEventsFunc. The
|
||||||
|
// returned `onMessage` closure bumps EventLogMutationErrors on any
|
||||||
|
// non-nil error from registry.Apply, and the deferred function bumps
|
||||||
|
// EventLogReplayTotal / Failures / Duration on the overall return.
|
||||||
|
func (s *DiskStorage[V]) loadReplayMetrics() (onMessage func(err error), done func(err error)) {
|
||||||
|
m := s.StateStorage.metrics
|
||||||
|
if m == nil {
|
||||||
|
noop := func(error) {}
|
||||||
|
return noop, noop
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
onMessage = func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
m.EventLogMutationErrors.Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done = func(err error) {
|
||||||
|
if err != nil {
|
||||||
|
m.EventLogReplayFailures.Inc()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.EventLogReplayTotal.Inc()
|
||||||
|
m.EventLogReplayDuration.Observe(time.Since(start).Seconds())
|
||||||
|
}
|
||||||
|
return onMessage, done
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordReplayFailure bumps EventLogReplayFailures when the open
|
||||||
|
// step fails before the Load loop runs (and therefore before the
|
||||||
|
// loadReplayMetrics closure is in scope). Nil-safe.
|
||||||
|
func (s *DiskStorage[V]) recordReplayFailure() {
|
||||||
|
if m := s.StateStorage.metrics; m != nil {
|
||||||
|
m.EventLogReplayFailures.Inc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
|
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
|
||||||
path := s.logPath(id)
|
path := s.logPath(id)
|
||||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -237,16 +356,25 @@ func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Gr
|
|||||||
|
|
||||||
fh, err := os.Open(path)
|
fh, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordReplayFailure()
|
||||||
return fmt.Errorf("open replay file: %w", err)
|
return fmt.Errorf("open replay file: %w", err)
|
||||||
}
|
}
|
||||||
defer fh.Close()
|
defer fh.Close()
|
||||||
|
trackApply, done := s.loadReplayMetrics()
|
||||||
index := 0
|
index := 0
|
||||||
return s.Load(fh, func(msg proto.Message, when time.Time) {
|
loadErr := s.Load(fh, func(msg proto.Message, when time.Time) {
|
||||||
if condition(msg, index, when) {
|
if condition(msg, index, when) {
|
||||||
s.registry.Apply(ctx, grain, msg)
|
results, _ := s.registry.Apply(ctx, grain, msg)
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Error != nil {
|
||||||
|
trackApply(r.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
index++
|
index++
|
||||||
})
|
})
|
||||||
|
done(loadErr)
|
||||||
|
return loadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
|
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
|
||||||
@@ -258,12 +386,21 @@ func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[
|
|||||||
|
|
||||||
fh, err := os.Open(path)
|
fh, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.recordReplayFailure()
|
||||||
return fmt.Errorf("open replay file: %w", err)
|
return fmt.Errorf("open replay file: %w", err)
|
||||||
}
|
}
|
||||||
defer fh.Close()
|
defer fh.Close()
|
||||||
return s.Load(fh, func(msg proto.Message, _ time.Time) {
|
trackApply, done := s.loadReplayMetrics()
|
||||||
s.registry.Apply(ctx, grain, msg)
|
loadErr := s.Load(fh, func(msg proto.Message, _ time.Time) {
|
||||||
|
results, _ := s.registry.Apply(ctx, grain, msg)
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Error != nil {
|
||||||
|
trackApply(r.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
done(loadErr)
|
||||||
|
return loadErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DiskStorage[V]) Close() {
|
func (s *DiskStorage[V]) Close() {
|
||||||
@@ -295,3 +432,65 @@ func (s *DiskStorage[V]) openWriterCount() int {
|
|||||||
defer s.writersMu.Unlock()
|
defer s.writersMu.Unlock()
|
||||||
return len(s.writers)
|
return len(s.writers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DiskStorage[V]) PurgeOlderThan(ctx context.Context, retention time.Duration, isGrainActive func(id uint64) bool) (int, error) {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := os.ReadDir(s.path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("read storage dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-retention)
|
||||||
|
purgedCount := 0
|
||||||
|
|
||||||
|
for _, entry := range files {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(name, ".events.log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var id uint64
|
||||||
|
_, err := fmt.Sscanf(name, "%d.events.log", &id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to get file info for %s: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.ModTime().Before(cutoff) {
|
||||||
|
// Check if grain is active in memory
|
||||||
|
if isGrainActive != nil && isGrainActive(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have an active writer open
|
||||||
|
s.writersMu.Lock()
|
||||||
|
_, hasWriter := s.writers[id]
|
||||||
|
s.writersMu.Unlock()
|
||||||
|
if hasWriter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
fullPath := filepath.Join(s.path, name)
|
||||||
|
if err := os.Remove(fullPath); err != nil {
|
||||||
|
log.Printf("failed to delete expired cart log %s: %v", fullPath, err)
|
||||||
|
} else {
|
||||||
|
purgedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return purgedCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package actor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -317,3 +318,86 @@ func TestDiskStorageConcurrentWritesDifferentIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiskStoragePurge(t *testing.T) {
|
||||||
|
reg := diskTestRegistry(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
storage := NewDiskStorage[testState](dir, reg)
|
||||||
|
t.Cleanup(storage.Close)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
|
||||||
|
|
||||||
|
// Write mutations to create event logs for 1, 2, 3, 4
|
||||||
|
ids := []uint64{1, 2, 3, 4}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := storage.AppendMutations(id, msg); err != nil {
|
||||||
|
t.Fatalf("append %d: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force save to disk
|
||||||
|
storage.save()
|
||||||
|
|
||||||
|
// 1. Grain 1: Old modification time, inactive, no writer -> should be purged
|
||||||
|
path1 := storage.logPath(1)
|
||||||
|
oldTime := time.Now().Add(-10 * 24 * time.Hour)
|
||||||
|
if err := os.Chtimes(path1, oldTime, oldTime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We must close the writer for grain 1, 2, 4 to make them eligible for purging (no open writer)
|
||||||
|
storage.writersMu.Lock()
|
||||||
|
for _, id := range []uint64{1, 2, 4} {
|
||||||
|
if w, ok := storage.writers[id]; ok {
|
||||||
|
w.purged = true
|
||||||
|
w.file.Close()
|
||||||
|
delete(storage.writers, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storage.writersMu.Unlock()
|
||||||
|
|
||||||
|
// 2. Grain 2: Old mod time, but marked active -> should NOT be purged
|
||||||
|
path2 := storage.logPath(2)
|
||||||
|
if err := os.Chtimes(path2, oldTime, oldTime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Grain 3: Old mod time, but has open writer -> should NOT be purged
|
||||||
|
path3 := storage.logPath(3)
|
||||||
|
if err := os.Chtimes(path3, oldTime, oldTime); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Grain 4: Recent mod time -> should NOT be purged
|
||||||
|
path4 := storage.logPath(4)
|
||||||
|
|
||||||
|
activeGrains := map[uint64]bool{2: true}
|
||||||
|
isGrainActive := func(id uint64) bool {
|
||||||
|
return activeGrains[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := storage.PurgeOlderThan(ctx, 5*24*time.Hour, isGrainActive)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PurgeOlderThan failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if purged != 1 {
|
||||||
|
t.Errorf("Expected exactly 1 purged file, got %d", purged)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file status
|
||||||
|
if _, err := os.Stat(path1); !os.IsNotExist(err) {
|
||||||
|
t.Error("Expected grain 1 log to be deleted")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path2); err != nil {
|
||||||
|
t.Error("Expected grain 2 log to exist (active)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path3); err != nil {
|
||||||
|
t.Error("Expected grain 3 log to exist (active writer)")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path4); err != nil {
|
||||||
|
t.Error("Expected grain 4 log to exist (recent)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-4
@@ -19,8 +19,13 @@ type GrainPool[V any] interface {
|
|||||||
OwnerHost(id uint64) (Host[V], bool)
|
OwnerHost(id uint64) (Host[V], bool)
|
||||||
Hostname() string
|
Hostname() string
|
||||||
TakeOwnership(id uint64)
|
TakeOwnership(id uint64)
|
||||||
HandleOwnershipChange(host string, ids []uint64) error
|
// HandleOwnershipChange processes a remote first-touch ownership
|
||||||
HandleRemoteExpiry(host string, ids []uint64) error
|
// claim. lastChanges is the parallel []int64 of UnixNano stamps from
|
||||||
|
// the announcer; -1 entries fall back to the pre-arbitration
|
||||||
|
// "always accept remote" behaviour so mixed-version rollouts
|
||||||
|
// remain safe.
|
||||||
|
HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error
|
||||||
|
HandleRemoteExpiry(host string, ids []uint64, lastChanges []int64) error
|
||||||
Negotiate(otherHosts []string)
|
Negotiate(otherHosts []string)
|
||||||
GetLocalIds() []uint64
|
GetLocalIds() []uint64
|
||||||
IsHealthy() bool
|
IsHealthy() bool
|
||||||
@@ -32,7 +37,13 @@ type GrainPool[V any] interface {
|
|||||||
|
|
||||||
// Host abstracts a remote node capable of proxying cart requests.
|
// Host abstracts a remote node capable of proxying cart requests.
|
||||||
type Host[V any] interface {
|
type Host[V any] interface {
|
||||||
AnnounceExpiry(ids []uint64)
|
// AnnounceExpiry broadcasts per-id eviction decisions. lastChanges is
|
||||||
|
// a parallel []int64 of UnixNano stamps taken at the moment the
|
||||||
|
// grain was dropped from the local cache; it is informational on
|
||||||
|
// this path (the receiver just removes the id from its remoteOwners
|
||||||
|
// map) but kept in the wire signature for symmetry with
|
||||||
|
// AnnounceOwnership.
|
||||||
|
AnnounceExpiry(ids []uint64, lastChanges []int64)
|
||||||
Negotiate(otherHosts []string) ([]string, error)
|
Negotiate(otherHosts []string) ([]string, error)
|
||||||
Name() string
|
Name() string
|
||||||
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
||||||
@@ -42,5 +53,14 @@ type Host[V any] interface {
|
|||||||
Close() error
|
Close() error
|
||||||
Ping() bool
|
Ping() bool
|
||||||
IsHealthy() bool
|
IsHealthy() bool
|
||||||
AnnounceOwnership(ownerHost string, ids []uint64)
|
// AnnounceOwnership broadcasts a first-touch ownership claim.
|
||||||
|
// lastChanges is a parallel []int64 of UnixNano stamps of the
|
||||||
|
// announcing pod's local grain's GetLastChange() at the moment of
|
||||||
|
// broadcast; receivers use it as the first-spawn-wins oracle for
|
||||||
|
// concurrent cold-cache first-touch (smaller stamp = older spawn =
|
||||||
|
// owns the grain; on equal stamps the lexicographically smaller
|
||||||
|
// hostname wins). A stamp of -1 means "no local grain to back this
|
||||||
|
// id" — receivers treat that as legacy/no-arbitration and accept
|
||||||
|
// the remote claim as before.
|
||||||
|
AnnounceOwnership(ownerHost string, ids []uint64, lastChanges []int64)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
|
|||||||
)
|
)
|
||||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
err := s.pool.HandleOwnershipChange(req.Host, req.Ids, req.LastChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
return &messages.OwnerChangeAck{
|
return &messages.OwnerChangeAck{
|
||||||
@@ -138,7 +138,7 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
|
|||||||
)
|
)
|
||||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||||
|
|
||||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids, req.LastChanges)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ func (m *mockGrainPool) TakeOwnership(id uint64) {}
|
|||||||
|
|
||||||
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
||||||
|
|
||||||
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
|
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64, _ []int64) error { return nil }
|
||||||
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
|
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error { return nil }
|
||||||
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
||||||
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
||||||
func (m *mockGrainPool) RemoveHost(host string) {}
|
func (m *mockGrainPool) RemoveHost(host string) {}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metrics bundles every prometheus metric the actor package emits. It is
|
||||||
|
// constructed per service (cart / checkout / profile / order) with a
|
||||||
|
// service-specific prefix so the metric names do not collide when more
|
||||||
|
// than one actor-pool service is scraped into the same Prometheus.
|
||||||
|
//
|
||||||
|
// All touch sites are nil-safe: the pool / storage / registry code wraps
|
||||||
|
// each counter / histogram call in a nil-check on the *Metrics pointer,
|
||||||
|
// so the existing tests (which construct a pool or storage without
|
||||||
|
// metrics) keep working without a forced dependency on this struct.
|
||||||
|
//
|
||||||
|
// Metric names match the panels in grafana_dashboard_cart.json exactly.
|
||||||
|
// The dashboard had `connected_remotes` (no prefix) as a typo; the
|
||||||
|
// source-of-truth metric exported here is `<prefix>_connected_remotes`
|
||||||
|
// and the dashboard JSON is updated in the same change to match.
|
||||||
|
type Metrics struct {
|
||||||
|
// Pool — set by SimpleGrainPool.
|
||||||
|
ActiveGrains prometheus.Gauge
|
||||||
|
GrainsInPool prometheus.Gauge
|
||||||
|
PoolUsage prometheus.Gauge
|
||||||
|
ConnectedRemotes prometheus.Gauge
|
||||||
|
GrainSpawnedTotal prometheus.Counter
|
||||||
|
GrainLookupsTotal prometheus.Counter
|
||||||
|
MutationsTotal prometheus.Counter
|
||||||
|
MutationFailuresTotal prometheus.Counter
|
||||||
|
MutationLatency prometheus.Histogram
|
||||||
|
RemoteNegotiationTotal prometheus.Counter
|
||||||
|
|
||||||
|
// Event log — set by DiskStorage and StateStorage.
|
||||||
|
EventLogAppends prometheus.Counter
|
||||||
|
EventLogBytesWritten prometheus.Counter
|
||||||
|
EventLogFilesExisting prometheus.Gauge
|
||||||
|
EventLogLastAppendUnix prometheus.Gauge
|
||||||
|
EventLogReplayTotal prometheus.Counter
|
||||||
|
EventLogReplayFailures prometheus.Counter
|
||||||
|
EventLogReplayDuration prometheus.Histogram
|
||||||
|
EventLogUnknownTypes prometheus.Counter
|
||||||
|
EventLogMutationErrors prometheus.Counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetrics registers every actor metric in the default Prometheus
|
||||||
|
// registry (the one promhttp.Handler() exposes on /metrics) with the
|
||||||
|
// service-specific prefix. The prefix is the same string the binary uses
|
||||||
|
// as its service name (e.g. "cart", "checkout", "profile", "order"),
|
||||||
|
// which keeps the metric names aligned with the per-service labels in
|
||||||
|
// grafana_dashboard_cart.json.
|
||||||
|
func NewMetrics(prefix string) *Metrics {
|
||||||
|
return &Metrics{
|
||||||
|
ActiveGrains: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_active_grains",
|
||||||
|
Help: "Number of grains currently held in the local pool.",
|
||||||
|
}),
|
||||||
|
GrainsInPool: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_grains_in_pool",
|
||||||
|
Help: "Alias of active_grains: the dashboard shows both as a sanity check.",
|
||||||
|
}),
|
||||||
|
PoolUsage: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_grain_pool_usage",
|
||||||
|
Help: "Local pool fill ratio: len(grains) / poolSize (range 0..1).",
|
||||||
|
}),
|
||||||
|
ConnectedRemotes: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_connected_remotes",
|
||||||
|
Help: "Number of remote actor-pool hosts currently connected via gRPC.",
|
||||||
|
}),
|
||||||
|
GrainSpawnedTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_grain_spawned_total",
|
||||||
|
Help: "Total number of grains spawned (loaded from disk or freshly created).",
|
||||||
|
}),
|
||||||
|
GrainLookupsTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_grain_lookups_total",
|
||||||
|
Help: "Total number of pool.Get calls (state reads).",
|
||||||
|
}),
|
||||||
|
MutationsTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_mutations_total",
|
||||||
|
Help: "Total number of mutations applied across all grains (one per proto message, not per call).",
|
||||||
|
}),
|
||||||
|
MutationFailuresTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_mutation_failures_total",
|
||||||
|
Help: "Total number of pool.Apply calls that returned an error.",
|
||||||
|
}),
|
||||||
|
// Latency buckets cover the typical pool.Apply (handler + registry
|
||||||
|
// apply + storage enqueue): sub-ms in the hot path, hundreds of ms
|
||||||
|
// under load. Tweak if a hot mutation type moves outside this range.
|
||||||
|
MutationLatency: promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: prefix + "_mutation_latency_seconds",
|
||||||
|
Help: "Wall-clock duration of a pool.Apply call (lock + spawn + handler + storage enqueue).",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(0.0005, 2, 14), // 0.5ms ... ~8s
|
||||||
|
}),
|
||||||
|
RemoteNegotiationTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_remote_negotiation_total",
|
||||||
|
Help: "Total number of cluster-negotiation rounds initiated by this pod.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
EventLogAppends: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_appends_total",
|
||||||
|
Help: "Total number of event-log line appends (one per persisted mutation message).",
|
||||||
|
}),
|
||||||
|
EventLogBytesWritten: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_bytes_written_total",
|
||||||
|
Help: "Total bytes written to per-grain .events.log files.",
|
||||||
|
}),
|
||||||
|
EventLogFilesExisting: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_event_log_files_existing",
|
||||||
|
Help: "Number of per-grain .events.log files currently on disk in the storage directory.",
|
||||||
|
}),
|
||||||
|
EventLogLastAppendUnix: promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: prefix + "_event_log_last_append_unix",
|
||||||
|
Help: "Unix timestamp of the last successful event-log append.",
|
||||||
|
}),
|
||||||
|
EventLogReplayTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_replay_total",
|
||||||
|
Help: "Total number of successful event-log replays (one per grain loaded from disk).",
|
||||||
|
}),
|
||||||
|
EventLogReplayFailures: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_replay_failures_total",
|
||||||
|
Help: "Total number of failed event-log replays (open error, JSON parse error, or handler error).",
|
||||||
|
}),
|
||||||
|
EventLogReplayDuration: promauto.NewHistogram(prometheus.HistogramOpts{
|
||||||
|
Name: prefix + "_event_log_replay_duration_seconds",
|
||||||
|
Help: "Wall-clock time to replay a single grain's event log from disk.",
|
||||||
|
Buckets: prometheus.ExponentialBuckets(0.001, 2, 14), // 1ms ... ~16s
|
||||||
|
}),
|
||||||
|
EventLogUnknownTypes: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_unknown_types_total",
|
||||||
|
Help: "Total number of event-log entries with a type the registry does not recognise.",
|
||||||
|
}),
|
||||||
|
EventLogMutationErrors: promauto.NewCounter(prometheus.CounterOpts{
|
||||||
|
Name: prefix + "_event_log_mutation_errors_total",
|
||||||
|
Help: "Total number of mutation-handler errors observed during event-log replay.",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MutationStarted returns a closure to be deferred at the top of
|
||||||
|
// pool.Apply so the latency observation / counters fire exactly once
|
||||||
|
// per call regardless of which return path is taken. mutationCount
|
||||||
|
// is the number of proto messages in the Apply call (a single call
|
||||||
|
// can batch several messages — SetCartItems, for example, sends
|
||||||
|
// ClearCart + N AddItem together); the counter is incremented by
|
||||||
|
// that count so the dashboard's `rate(cart_mutations_total[1m])`
|
||||||
|
// reports "messages per second" rather than "calls per second",
|
||||||
|
// matching the original `grainMutations.Add(len(data.Mutations))`
|
||||||
|
// semantics. The returned closure is nil-safe — pass a nil *Metrics
|
||||||
|
// in and the call is a no-op.
|
||||||
|
func (m *Metrics) MutationStarted(mutationCount int) func(err error) {
|
||||||
|
if m == nil {
|
||||||
|
return func(error) {}
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
return func(err error) {
|
||||||
|
if mutationCount > 0 {
|
||||||
|
m.MutationsTotal.Add(float64(mutationCount))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
m.MutationFailuresTotal.Inc()
|
||||||
|
}
|
||||||
|
m.MutationLatency.Observe(time.Since(start).Seconds())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPoolStats updates the three pool-level gauges (grains_in_pool,
|
||||||
|
// active_grains, pool_usage) in one call. Safe to invoke under the
|
||||||
|
// pool's localMu — it does not take any lock itself, only prometheus
|
||||||
|
// atomic counters.
|
||||||
|
func (m *Metrics) SetPoolStats(grains, poolSize int) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.GrainsInPool.Set(float64(grains))
|
||||||
|
m.ActiveGrains.Set(float64(grains))
|
||||||
|
if poolSize > 0 {
|
||||||
|
m.PoolUsage.Set(float64(grains) / float64(poolSize))
|
||||||
|
} else {
|
||||||
|
m.PoolUsage.Set(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncSpawn bumps the grain-spawned counter. Nil-safe.
|
||||||
|
func (m *Metrics) IncSpawn() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.GrainSpawnedTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncLookup bumps the grain-lookups counter. Nil-safe.
|
||||||
|
func (m *Metrics) IncLookup() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.GrainLookupsTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncNegotiation bumps the remote-negotiation counter. Nil-safe.
|
||||||
|
func (m *Metrics) IncNegotiation() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.RemoteNegotiationTotal.Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConnectedRemotes updates the connected-remotes gauge. Nil-safe.
|
||||||
|
func (m *Metrics) SetConnectedRemotes(n int) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.ConnectedRemotes.Set(float64(n))
|
||||||
|
}
|
||||||
+161
-17
@@ -28,6 +28,12 @@ type SimpleGrainPool[V any] struct {
|
|||||||
// grain processes a single message at a time (the actor guarantee).
|
// grain processes a single message at a time (the actor guarantee).
|
||||||
grainLocks *keyedMutex
|
grainLocks *keyedMutex
|
||||||
|
|
||||||
|
// metrics is the prometheus instrumentation wired in by NewMetrics
|
||||||
|
// at composition time. Nil is fine — every touch site nil-checks
|
||||||
|
// first, so the existing tests (which construct a pool without
|
||||||
|
// metrics) keep working.
|
||||||
|
metrics *Metrics
|
||||||
|
|
||||||
// Cluster coordination --------------------------------------------------
|
// Cluster coordination --------------------------------------------------
|
||||||
hostname string
|
hostname string
|
||||||
remoteMu sync.RWMutex
|
remoteMu sync.RWMutex
|
||||||
@@ -39,6 +45,16 @@ type SimpleGrainPool[V any] struct {
|
|||||||
purgeTicker *time.Ticker
|
purgeTicker *time.Ticker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noLocalStamp is the sentinel value used in the parallel []int64
|
||||||
|
// lastChanges slice to mark an id for which the broadcaster has no
|
||||||
|
// local grain (e.g. TakeOwnership called before anyone has loaded the
|
||||||
|
// grain, or a custom Host implementation that doesn't carry a stamp).
|
||||||
|
// Receivers treat this sentinel as "no arbitration possible" and fall
|
||||||
|
// back to the pre-arbitration "always accept remote" verdict so the
|
||||||
|
// behaviour is identical to running an older binary that didn't carry
|
||||||
|
// stamps at all.
|
||||||
|
const noLocalStamp int64 = -1
|
||||||
|
|
||||||
type GrainPoolConfig[V any] struct {
|
type GrainPoolConfig[V any] struct {
|
||||||
Hostname string
|
Hostname string
|
||||||
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||||
@@ -51,6 +67,13 @@ type GrainPoolConfig[V any] struct {
|
|||||||
PoolSize int
|
PoolSize int
|
||||||
MutationRegistry MutationRegistry
|
MutationRegistry MutationRegistry
|
||||||
Storage LogStorage[V]
|
Storage LogStorage[V]
|
||||||
|
// Metrics is the prometheus instrumentation to register pool-level
|
||||||
|
// counters, gauges, and histograms with. Nil-safe: when unset, all
|
||||||
|
// touch sites are no-ops. Use actor.NewMetrics("cart") (or the
|
||||||
|
// matching service prefix) to construct the per-service metrics
|
||||||
|
// struct, and pair it with DiskStorage.SetMetrics so the same
|
||||||
|
// Metrics instance covers both pool and event-log metrics.
|
||||||
|
Metrics *Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
|
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
|
||||||
@@ -64,6 +87,7 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
|||||||
ttl: config.TTL,
|
ttl: config.TTL,
|
||||||
poolSize: config.PoolSize,
|
poolSize: config.PoolSize,
|
||||||
hostname: config.Hostname,
|
hostname: config.Hostname,
|
||||||
|
metrics: config.Metrics,
|
||||||
remoteOwners: make(map[uint64]Host[V]),
|
remoteOwners: make(map[uint64]Host[V]),
|
||||||
remoteHosts: make(map[string]Host[V]),
|
remoteHosts: make(map[string]Host[V]),
|
||||||
grainLocks: newKeyedMutex(),
|
grainLocks: newKeyedMutex(),
|
||||||
@@ -76,6 +100,11 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Publish the initial pool stats so the dashboard has a number to
|
||||||
|
// draw on first scrape, rather than waiting for the first grain
|
||||||
|
// spawn / eviction to bump the gauges.
|
||||||
|
p.metrics.SetPoolStats(0, p.poolSize)
|
||||||
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,22 +124,31 @@ func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
|||||||
func (p *SimpleGrainPool[V]) purge() {
|
func (p *SimpleGrainPool[V]) purge() {
|
||||||
purgeLimit := time.Now().Add(-p.ttl)
|
purgeLimit := time.Now().Add(-p.ttl)
|
||||||
purgedIds := make([]uint64, 0, len(p.grains))
|
purgedIds := make([]uint64, 0, len(p.grains))
|
||||||
|
purgedStamps := make([]int64, 0, len(p.grains))
|
||||||
p.localMu.Lock()
|
p.localMu.Lock()
|
||||||
|
evicted := 0
|
||||||
for id, grain := range p.grains {
|
for id, grain := range p.grains {
|
||||||
if grain.GetLastAccess().Before(purgeLimit) {
|
if grain.GetLastAccess().Before(purgeLimit) {
|
||||||
purgedIds = append(purgedIds, id)
|
|
||||||
if p.destroy != nil {
|
if p.destroy != nil {
|
||||||
if err := p.destroy(grain); err != nil {
|
if err := p.destroy(grain); err != nil {
|
||||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
log.Printf("failed to destroy grain %d: %v", id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Capture the lastChange stamp BEFORE delete so peers
|
||||||
|
// receive an honest per-id eviction record on the wire.
|
||||||
|
purgedIds = append(purgedIds, id)
|
||||||
|
purgedStamps = append(purgedStamps, grain.GetLastChange().UnixNano())
|
||||||
delete(p.grains, id)
|
delete(p.grains, id)
|
||||||
|
evicted++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
remaining := len(p.grains)
|
||||||
p.localMu.Unlock()
|
p.localMu.Unlock()
|
||||||
|
if evicted > 0 {
|
||||||
|
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||||
|
}
|
||||||
p.forAllHosts(func(remote Host[V]) {
|
p.forAllHosts(func(remote Host[V]) {
|
||||||
remote.AnnounceExpiry(purgedIds)
|
remote.AnnounceExpiry(purgedIds, purgedStamps)
|
||||||
})
|
})
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -136,7 +174,14 @@ func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
|
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error {
|
||||||
|
// lastChanges is informational on this path; expiry is unilateral
|
||||||
|
// from the announcer's perspective — we just drop the id from any
|
||||||
|
// remoteOwners mapping so future cross-pod forwards stop hitting a
|
||||||
|
// dead peer. The parallel stamp slice is kept in the signature for
|
||||||
|
// wire symmetry with AnnounceOwnership and so a future hardening
|
||||||
|
// pass can use stamps to detect ghost-evictions (e.g. peer evicted
|
||||||
|
// with a stamp newer than our last seen Apply result).
|
||||||
p.remoteMu.Lock()
|
p.remoteMu.Lock()
|
||||||
defer p.remoteMu.Unlock()
|
defer p.remoteMu.Unlock()
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
@@ -145,7 +190,7 @@ func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
|
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error {
|
||||||
p.remoteMu.RLock()
|
p.remoteMu.RLock()
|
||||||
remoteHost, exists := p.remoteHosts[host]
|
remoteHost, exists := p.remoteHosts[host]
|
||||||
p.remoteMu.RUnlock()
|
p.remoteMu.RUnlock()
|
||||||
@@ -156,14 +201,75 @@ func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) er
|
|||||||
}
|
}
|
||||||
remoteHost = createdHost
|
remoteHost = createdHost
|
||||||
}
|
}
|
||||||
|
// Lock order: remoteMu before localMu. Unlock in reverse order so
|
||||||
|
// the localMu gauge read below cannot deadlock against a concurrent
|
||||||
|
// p.localMu holder.
|
||||||
p.remoteMu.Lock()
|
p.remoteMu.Lock()
|
||||||
defer p.remoteMu.Unlock()
|
|
||||||
p.localMu.Lock()
|
p.localMu.Lock()
|
||||||
defer p.localMu.Unlock()
|
var accepted, kept, legacy int
|
||||||
for _, id := range ids {
|
for i, id := range ids {
|
||||||
log.Printf("Handling ownership change for cart %d to host %s", id, host)
|
// remoteStamp is the announcer's lastChange at broadcast time,
|
||||||
|
// or noLocalStamp (-1) if the announcer didn't have a local
|
||||||
|
// grain (legacy / TakeOwnership path). The
|
||||||
|
// 0..len(lastChanges)-1 slice is indexed parallel to ids; out
|
||||||
|
// of range falls back to noLocalStamp (= "no arbitration").
|
||||||
|
var remoteStamp int64 = noLocalStamp
|
||||||
|
if i < len(lastChanges) {
|
||||||
|
remoteStamp = lastChanges[i]
|
||||||
|
}
|
||||||
|
var localStamp int64 = noLocalStamp
|
||||||
|
var hasLocal bool
|
||||||
|
if g, ok := p.grains[id]; ok {
|
||||||
|
localStamp = g.GetLastChange().UnixNano()
|
||||||
|
hasLocal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// First-spawn-wins arbitration. Five distinct cases, ordered:
|
||||||
|
// 1) remoteStamp == noLocalStamp → announcer didn't carry a
|
||||||
|
// real stamp (legacy or TakeOwnership). Keep current
|
||||||
|
// "always accept remote" behaviour so mixed-version
|
||||||
|
// rollouts and unbroadcast-advertised TakeOwnership don't
|
||||||
|
// regress.
|
||||||
|
// 2) localStamp == noLocalStamp → we have no grain at all.
|
||||||
|
// Accept remote so the next read forwards to the right
|
||||||
|
// owner instead of us spawning a parallel projection.
|
||||||
|
// 3) stamps differ → first-spawn-wins. The pod that spawned
|
||||||
|
// the grain earlier (smaller stamp) owns it; its broadcast
|
||||||
|
// is authoritative. The newer pod's projection is
|
||||||
|
// redundant — we drop our local copy and defer to remote
|
||||||
|
// so future OwnerHost lookups route correctly.
|
||||||
|
// 4) stamps equal → deterministic tie-break: lexicographically
|
||||||
|
// smaller hostname wins (both pods compute the same
|
||||||
|
// verdict, so the cold-cache first-touch cannot flip-flop).
|
||||||
|
var keepLocal bool
|
||||||
|
switch {
|
||||||
|
case remoteStamp == noLocalStamp:
|
||||||
|
keepLocal = false
|
||||||
|
if hasLocal {
|
||||||
|
legacy++
|
||||||
|
}
|
||||||
|
case localStamp == noLocalStamp:
|
||||||
|
keepLocal = false
|
||||||
|
case localStamp != remoteStamp:
|
||||||
|
keepLocal = localStamp < remoteStamp
|
||||||
|
default:
|
||||||
|
keepLocal = p.hostname < host
|
||||||
|
}
|
||||||
|
if keepLocal {
|
||||||
|
kept++
|
||||||
|
continue
|
||||||
|
}
|
||||||
delete(p.grains, id)
|
delete(p.grains, id)
|
||||||
p.remoteOwners[id] = remoteHost
|
p.remoteOwners[id] = remoteHost
|
||||||
|
accepted++
|
||||||
|
}
|
||||||
|
remaining := len(p.grains)
|
||||||
|
p.localMu.Unlock()
|
||||||
|
p.remoteMu.Unlock()
|
||||||
|
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||||
|
if kept > 0 || accepted > 0 || legacy > 0 {
|
||||||
|
log.Printf("HandleOwnershipChange from %s: accepted_remote=%d kept_local=%d legacy_no_stamp=%d",
|
||||||
|
host, accepted, kept, legacy)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -209,8 +315,9 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
|
|||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
p.remoteHosts[host] = remote
|
p.remoteHosts[host] = remote
|
||||||
|
count := len(p.remoteHosts)
|
||||||
p.remoteMu.Unlock()
|
p.remoteMu.Unlock()
|
||||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
p.metrics.SetConnectedRemotes(count)
|
||||||
|
|
||||||
log.Printf("Connected to remote host %s", host)
|
log.Printf("Connected to remote host %s", host)
|
||||||
go p.pingLoop(remote)
|
go p.pingLoop(remote)
|
||||||
@@ -250,6 +357,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
|||||||
delete(p.remoteOwners, id)
|
delete(p.remoteOwners, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
remoteCount := len(p.remoteHosts)
|
||||||
log.Printf("Removing host %s, grains: %d", host, count)
|
log.Printf("Removing host %s, grains: %d", host, count)
|
||||||
p.remoteMu.Unlock()
|
p.remoteMu.Unlock()
|
||||||
|
|
||||||
@@ -257,7 +365,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
|||||||
if exists {
|
if exists {
|
||||||
remote.Close()
|
remote.Close()
|
||||||
}
|
}
|
||||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
p.metrics.SetConnectedRemotes(remoteCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) RemoteCount() int {
|
func (p *SimpleGrainPool[V]) RemoteCount() int {
|
||||||
@@ -332,7 +440,7 @@ func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *SimpleGrainPool[V]) SendNegotiation() {
|
func (p *SimpleGrainPool[V]) SendNegotiation() {
|
||||||
//negotiationCount.Inc()
|
p.metrics.IncNegotiation()
|
||||||
|
|
||||||
p.remoteMu.RLock()
|
p.remoteMu.RLock()
|
||||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||||
@@ -386,8 +494,27 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture each id's grain.lastChange UnixNano stamp under the local
|
||||||
|
// read-lock so peers receive an honest oracle for the
|
||||||
|
// first-spawn-wins arbitration. For ids that don't have a local
|
||||||
|
// grain (e.g. TakeOwnership called before anyone has loaded the
|
||||||
|
// grain, or a caller pushing ids into the pool from outside the
|
||||||
|
// spawn path), substitute the noLocalStamp sentinel — receivers see
|
||||||
|
// -1 and fall back to "always accept remote", preserving the
|
||||||
|
// pre-arbitration semantics for that case.
|
||||||
|
p.localMu.RLock()
|
||||||
|
stamps := make([]int64, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if g, ok := p.grains[id]; ok {
|
||||||
|
stamps = append(stamps, g.GetLastChange().UnixNano())
|
||||||
|
} else {
|
||||||
|
stamps = append(stamps, noLocalStamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.localMu.RUnlock()
|
||||||
|
|
||||||
p.forAllHosts(func(rh Host[V]) {
|
p.forAllHosts(func(rh Host[V]) {
|
||||||
rh.AnnounceOwnership(p.hostname, ids)
|
rh.AnnounceOwnership(p.hostname, ids, stamps)
|
||||||
})
|
})
|
||||||
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
||||||
// go p.statsUpdate()
|
// go p.statsUpdate()
|
||||||
@@ -405,10 +532,13 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.metrics.IncSpawn()
|
||||||
go p.broadcastOwnership([]uint64{id})
|
go p.broadcastOwnership([]uint64{id})
|
||||||
p.localMu.Lock()
|
p.localMu.Lock()
|
||||||
p.grains[id] = grain
|
p.grains[id] = grain
|
||||||
|
remaining := len(p.grains)
|
||||||
p.localMu.Unlock()
|
p.localMu.Unlock()
|
||||||
|
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||||
|
|
||||||
return grain, nil
|
return grain, nil
|
||||||
}
|
}
|
||||||
@@ -417,7 +547,20 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
|||||||
// var ErrNotOwner = fmt.Errorf("not owner")
|
// var ErrNotOwner = fmt.Errorf("not owner")
|
||||||
|
|
||||||
// Apply applies a mutation to a grain.
|
// Apply applies a mutation to a grain.
|
||||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
|
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (result *MutationResult[V], err error) {
|
||||||
|
// The metric closure fires exactly once on every return path: the
|
||||||
|
// latency / counters cover the full Apply (lock acquire + spawn +
|
||||||
|
// handler + storage enqueue), and the failure counter only bumps
|
||||||
|
// when the call returned a non-nil error. `len(mutation)` is the
|
||||||
|
// number of proto messages in this Apply call — a single call can
|
||||||
|
// batch several messages (SetCartItems sends ClearCart + N AddItem
|
||||||
|
// together), and the counter is incremented by that count so the
|
||||||
|
// dashboard's `rate(cart_mutations_total[1m])` reports messages
|
||||||
|
// per second rather than calls per second, matching the original
|
||||||
|
// `grainMutations.Add(len(data.Mutations))` semantics.
|
||||||
|
done := p.metrics.MutationStarted(len(mutation))
|
||||||
|
defer func() { done(err) }()
|
||||||
|
|
||||||
// Serialize all access to this grain: spawn, mutation handlers and the
|
// Serialize all access to this grain: spawn, mutation handlers and the
|
||||||
// final state read happen atomically with respect to other callers of the
|
// final state read happen atomically with respect to other callers of the
|
||||||
// same id. Different ids never contend.
|
// same id. Different ids never contend.
|
||||||
@@ -443,19 +586,20 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
|
|||||||
for _, listener := range p.listeners {
|
for _, listener := range p.listeners {
|
||||||
go listener.AppendMutations(id, mutations...)
|
go listener.AppendMutations(id, mutations...)
|
||||||
}
|
}
|
||||||
result, err := grain.GetCurrentState()
|
state, err := grain.GetCurrentState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &MutationResult[V]{
|
return &MutationResult[V]{
|
||||||
Result: *result,
|
Result: *state,
|
||||||
Mutations: mutations,
|
Mutations: mutations,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the current state of a grain.
|
// Get returns the current state of a grain.
|
||||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
|
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (result *V, err error) {
|
||||||
|
p.metrics.IncLookup()
|
||||||
unlock := p.grainLocks.lock(id)
|
unlock := p.grainLocks.lock(id)
|
||||||
defer unlock()
|
defer unlock()
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func newMockHost(name string) *mockHost {
|
|||||||
return &mockHost{name: name, healthy: true}
|
return &mockHost{name: name, healthy: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockHost) AnnounceExpiry(ids []uint64) {
|
func (m *mockHost) AnnounceExpiry(ids []uint64, _ []int64) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.expiry = append(m.expiry, ids...)
|
m.expiry = append(m.expiry, ids...)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -84,7 +84,7 @@ func (m *mockHost) Ping() bool { return m.healthy }
|
|||||||
|
|
||||||
func (m *mockHost) IsHealthy() bool { return m.healthy }
|
func (m *mockHost) IsHealthy() bool { return m.healthy }
|
||||||
|
|
||||||
func (m *mockHost) AnnounceOwnership(_ string, ids []uint64) {
|
func (m *mockHost) AnnounceOwnership(_ string, ids []uint64, _ []int64) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.ownership = append(m.ownership, ids...)
|
m.ownership = append(m.ownership, ids...)
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
@@ -247,11 +247,19 @@ func TestSimpleGrainPoolHandleOwnershipChange(t *testing.T) {
|
|||||||
host := newMockHost("peer-d")
|
host := newMockHost("peer-d")
|
||||||
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||||
|
|
||||||
|
// First-spawn-wins arbitration: a remote broadcast wins only when its
|
||||||
|
// lastChange stamp is strictly earlier than the local grain's stamp
|
||||||
|
// (or the local grain is absent). Capture the local spawn time once
|
||||||
|
// and pass a stamp 1ns earlier to the remote so the test continues to
|
||||||
|
// assert "external owner takes over given an earlier remote spawn"
|
||||||
|
// under the new arbitration rule.
|
||||||
|
spawnTime := time.Now()
|
||||||
pool.localMu.Lock()
|
pool.localMu.Lock()
|
||||||
pool.grains[7] = &testGrain{id: 7, accessed: time.Now()}
|
pool.grains[7] = &testGrain{id: 7, accessed: spawnTime}
|
||||||
pool.localMu.Unlock()
|
pool.localMu.Unlock()
|
||||||
|
|
||||||
if err := pool.HandleOwnershipChange(host.name, []uint64{7}); err != nil {
|
remoteStamp := spawnTime.UnixNano() - 1
|
||||||
|
if err := pool.HandleOwnershipChange(host.name, []uint64{7}, []int64{remoteStamp}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +276,121 @@ func TestSimpleGrainPoolHandleOwnershipChange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSimpleGrainPoolHandleOwnershipChangeArbitration verifies the
|
||||||
|
// first-spawn-wins arbitration rules for HandleOwnershipChange:
|
||||||
|
//
|
||||||
|
// - Remote with earlier lastChange than local → accept remote.
|
||||||
|
// - Remote with later lastChange than local → keep local.
|
||||||
|
// - Equal lastChange → lower hostname wins.
|
||||||
|
// - Remote stamp == -1 (legacy path) → accept remote (pre-arbitration).
|
||||||
|
// - Local grain absent → accept remote.
|
||||||
|
func TestSimpleGrainPoolHandleOwnershipChangeArbitration(t *testing.T) {
|
||||||
|
host := newMockHost("peer-arbitrate")
|
||||||
|
// Each table case constructs a fresh pool inside t.Run — we don't
|
||||||
|
// share state across cases because per-case hostname differs
|
||||||
|
// (hostname is the tie-break oracle on equal lastChange stamps).
|
||||||
|
|
||||||
|
now := time.Now().UnixNano()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
name_local string // pool hostname for ordering check
|
||||||
|
id uint64
|
||||||
|
localStamp int64 // UnixNano of the local grain; -1 = no local grain
|
||||||
|
remoteStamp int64 // UnixNano stamped on the broadcast
|
||||||
|
expectLocal bool // true → local grain still resident
|
||||||
|
expectRemote bool // true → remoteOwners[id] = host
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remote older stamp wins",
|
||||||
|
name_local: "zzz", // pool above "peer-arbitrate" lex
|
||||||
|
id: 10,
|
||||||
|
localStamp: now + 100,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "local earlier stamp keeps",
|
||||||
|
name_local: "aaa", // pool below "peer-arbitrate" lex but stamp decides
|
||||||
|
id: 11,
|
||||||
|
localStamp: now,
|
||||||
|
remoteStamp: now + 100,
|
||||||
|
expectLocal: true,
|
||||||
|
expectRemote: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "equal stamp + lower local hostname keeps",
|
||||||
|
name_local: "a-peer", // below host
|
||||||
|
id: 12,
|
||||||
|
localStamp: now,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: true,
|
||||||
|
expectRemote: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "equal stamp + higher local hostname defers",
|
||||||
|
name_local: "zzz-peer", // above host
|
||||||
|
id: 13,
|
||||||
|
localStamp: now,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy (no remote stamp) accepts remote",
|
||||||
|
name_local: "zzz", // we'd otherwise keep local
|
||||||
|
id: 14,
|
||||||
|
localStamp: now + 100,
|
||||||
|
remoteStamp: -1,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no local grain accepts remote",
|
||||||
|
name_local: "aaa",
|
||||||
|
id: 15,
|
||||||
|
localStamp: -1,
|
||||||
|
remoteStamp: now,
|
||||||
|
expectLocal: false,
|
||||||
|
expectRemote: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Fresh pool per case so hostname differences don't leak.
|
||||||
|
p := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||||
|
p.hostname = tc.name_local
|
||||||
|
|
||||||
|
p.localMu.Lock()
|
||||||
|
if tc.localStamp != -1 {
|
||||||
|
p.grains[tc.id] = &testGrain{id: tc.id, accessed: time.Unix(0, tc.localStamp)}
|
||||||
|
}
|
||||||
|
p.localMu.Unlock()
|
||||||
|
|
||||||
|
if err := p.HandleOwnershipChange(host.name, []uint64{tc.id}, []int64{tc.remoteStamp}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.localMu.RLock()
|
||||||
|
_, hasLocal := p.grains[tc.id]
|
||||||
|
p.localMu.RUnlock()
|
||||||
|
if hasLocal != tc.expectLocal {
|
||||||
|
t.Fatalf("local grain presence = %v, want %v", hasLocal, tc.expectLocal)
|
||||||
|
}
|
||||||
|
|
||||||
|
owner, ok := p.OwnerHost(tc.id)
|
||||||
|
if ok != tc.expectRemote {
|
||||||
|
t.Fatalf("OwnerHost ok = %v, want %v", ok, tc.expectRemote)
|
||||||
|
}
|
||||||
|
if tc.expectRemote && (owner == nil || owner.Name() != host.name) {
|
||||||
|
t.Fatalf("Owner = %v, want host %q", owner, host.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSimpleGrainPoolPurgeEvictsStaleGrains(t *testing.T) {
|
func TestSimpleGrainPoolPurgeEvictsStaleGrains(t *testing.T) {
|
||||||
pool := newTestPool(t, func(string) (Host[testState], error) {
|
pool := newTestPool(t, func(string) (Host[testState], error) {
|
||||||
return nil, fmt.Errorf("no remotes")
|
return nil, fmt.Errorf("no remotes")
|
||||||
|
|||||||
+29
-7
@@ -12,6 +12,12 @@ import (
|
|||||||
|
|
||||||
type StateStorage struct {
|
type StateStorage struct {
|
||||||
registry MutationRegistry
|
registry MutationRegistry
|
||||||
|
// metrics is set by the owning DiskStorage via SetMetrics. Nil is fine —
|
||||||
|
// every method that touches it nil-checks first. The pointer is
|
||||||
|
// intentionally unexported: callers set it through DiskStorage.SetMetrics
|
||||||
|
// so the embedded StateStorage always tracks the parent disk storage's
|
||||||
|
// metrics, even if the embedded value is replaced.
|
||||||
|
metrics *Metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageEvent struct {
|
type StorageEvent struct {
|
||||||
@@ -50,10 +56,20 @@ func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeS
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
|
// Append serialises a mutation as a StorageEvent and writes it (plus a
|
||||||
|
// trailing newline) to io. The first return value is the number of
|
||||||
|
// bytes written, which the caller (DiskStorage) adds to the
|
||||||
|
// event_log_bytes_written_total counter; the second is the underlying
|
||||||
|
// write error, if any. ErrUnknownType is returned (with 0 bytes) when
|
||||||
|
// the mutation's type is not registered, and is also counted in
|
||||||
|
// event_log_unknown_types_total.
|
||||||
|
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) (int, error) {
|
||||||
typeName, ok := s.registry.GetTypeName(mutation)
|
typeName, ok := s.registry.GetTypeName(mutation)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrUnknownType
|
if s.metrics != nil {
|
||||||
|
s.metrics.EventLogUnknownTypes.Inc()
|
||||||
|
}
|
||||||
|
return 0, ErrUnknownType
|
||||||
}
|
}
|
||||||
event := &StorageEvent{
|
event := &StorageEvent{
|
||||||
Type: typeName,
|
Type: typeName,
|
||||||
@@ -62,13 +78,16 @@ func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp ti
|
|||||||
}
|
}
|
||||||
jsonBytes, err := json.Marshal(event)
|
jsonBytes, err := json.Marshal(event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
if _, err := io.Write(jsonBytes); err != nil {
|
n, err := io.Write(jsonBytes)
|
||||||
return err
|
total := n
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
}
|
}
|
||||||
io.Write([]byte("\n"))
|
n, err = io.Write([]byte("\n"))
|
||||||
return nil
|
total += n
|
||||||
|
return total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
||||||
@@ -83,6 +102,9 @@ func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
|||||||
typeName := event.Type
|
typeName := event.Type
|
||||||
mutation, ok := s.registry.Create(typeName)
|
mutation, ok := s.registry.Create(typeName)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if s.metrics != nil {
|
||||||
|
s.metrics.EventLogUnknownTypes.Inc()
|
||||||
|
}
|
||||||
return nil, ErrUnknownType
|
return nil, ErrUnknownType
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
|
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"git.k6n.net/mats/platform/rabbit"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
@@ -49,14 +51,17 @@ type Config struct {
|
|||||||
CheckoutDataDir string
|
CheckoutDataDir string
|
||||||
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
||||||
ProfileDataDir string
|
ProfileDataDir string
|
||||||
|
// PromotionsFile holds the path to promotions.json (optional).
|
||||||
|
PromotionsFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// App is the commerce admin control plane. Construct with New, register its
|
// App is the commerce admin control plane. Construct with New, register its
|
||||||
// HTTP surface with RegisterRoutes, run background work with Start.
|
// HTTP surface with RegisterRoutes, run background work with Start.
|
||||||
type App struct {
|
type App struct {
|
||||||
fs *FileServer
|
fs *FileServer
|
||||||
hub *Hub
|
hub *Hub
|
||||||
cs *CustomerServer
|
cs *CustomerServer
|
||||||
|
OnVouchersChange func(codes []string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs the commerce admin: it opens the cart/checkout disk event-log
|
// New constructs the commerce admin: it opens the cart/checkout disk event-log
|
||||||
@@ -75,6 +80,72 @@ func New(cfg Config) (*App, error) {
|
|||||||
|
|
||||||
_ = os.MkdirAll(cfg.DataDir, 0755)
|
_ = os.MkdirAll(cfg.DataDir, 0755)
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
|
||||||
|
promotionsPath := cfg.PromotionsFile
|
||||||
|
if promotionsPath == "" {
|
||||||
|
promotionsPath = os.Getenv("PROMOTIONS_FILE")
|
||||||
|
}
|
||||||
|
if promotionsPath == "" {
|
||||||
|
promotionsPath = "data/promotions.json"
|
||||||
|
}
|
||||||
|
if promotionStore, err := promotions.NewStore(promotionsPath); err == nil {
|
||||||
|
promotionService := promotions.NewPromotionService(nil)
|
||||||
|
reg.RegisterProcessor(
|
||||||
|
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil {
|
||||||
|
v.BypassedByPromotions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.UpdateTotals()
|
||||||
|
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
|
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
|
|
||||||
|
hasBypassed := false
|
||||||
|
for _, res := range results {
|
||||||
|
if res.Applicable {
|
||||||
|
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
||||||
|
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
||||||
|
var codes []string
|
||||||
|
if s, ok := bc.Value.AsString(); ok {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
||||||
|
for _, s := range arr {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range codes {
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil && strings.ToLower(v.Code) == code {
|
||||||
|
if !v.BypassedByPromotions {
|
||||||
|
v.BypassedByPromotions = true
|
||||||
|
hasBypassed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasBypassed {
|
||||||
|
g.UpdateTotals()
|
||||||
|
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||||
|
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
promotionService.ApplyResults(g, results, promotionCtx)
|
||||||
|
g.Version++
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log.Printf("backofficeadmin: unable to load promotions store from %s: %v", promotionsPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
|
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
|
||||||
|
|
||||||
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
|
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
|
||||||
@@ -91,7 +162,7 @@ func New(cfg Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
|
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, promotionsPath, diskStorage, diskStorageCheckout),
|
||||||
hub: NewHub(),
|
hub: NewHub(),
|
||||||
cs: customerSrv,
|
cs: customerSrv,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -101,6 +172,7 @@ func New(cfg Config) (*App, error) {
|
|||||||
// apply auth or CORS — the composing host (or standalone main) wraps the mux
|
// apply auth or CORS — the composing host (or standalone main) wraps the mux
|
||||||
// with those.
|
// with those.
|
||||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
a.fs.OnVouchersChange = a.OnVouchersChange
|
||||||
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
|
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
|
||||||
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
|
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
|
||||||
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
|
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
|
||||||
|
|||||||
@@ -24,16 +24,19 @@ import (
|
|||||||
|
|
||||||
type FileServer struct {
|
type FileServer struct {
|
||||||
// Define fields here
|
// Define fields here
|
||||||
dataDir string
|
dataDir string
|
||||||
checkoutDataDir string
|
checkoutDataDir string
|
||||||
storage actor.LogStorage[cart.CartGrain]
|
promotionsFile string
|
||||||
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
storage actor.LogStorage[cart.CartGrain]
|
||||||
|
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
||||||
|
OnVouchersChange func(codes []string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
func NewFileServer(dataDir string, checkoutDataDir string, promotionsFile string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
||||||
return &FileServer{
|
return &FileServer{
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
checkoutDataDir: checkoutDataDir,
|
checkoutDataDir: checkoutDataDir,
|
||||||
|
promotionsFile: promotionsFile,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
checkoutStorage: checkoutStorage,
|
checkoutStorage: checkoutStorage,
|
||||||
}
|
}
|
||||||
@@ -215,7 +218,10 @@ func (fs *FileServer) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
fileName := fs.promotionsFile
|
||||||
|
if fileName == "" {
|
||||||
|
fileName = filepath.Join(fs.dataDir, "promotions.json")
|
||||||
|
}
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
file, err := os.Open(fileName)
|
file, err := os.Open(fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -271,15 +277,37 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
body, err := io.ReadAll(r.Body)
|
||||||
file, err := os.Create(fileName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
io.Copy(file, r.Body)
|
var parsed struct {
|
||||||
|
State struct {
|
||||||
|
Vouchers []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"vouchers"`
|
||||||
|
} `json:"state"`
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
if err := json.Unmarshal(body, &parsed); err == nil {
|
||||||
|
for _, v := range parsed.State.Vouchers {
|
||||||
|
if v.Code != "" {
|
||||||
|
codes = append(codes, v.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
||||||
|
if err := os.WriteFile(fileName, body, 0644); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if fs.OnVouchersChange != nil {
|
||||||
|
fs.OnVouchersChange(codes)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# `pkg/cart` — mutation inventory
|
||||||
|
|
||||||
|
Every mutation file under `pkg/cart/mutation_*.go` plus the slice of cart
|
||||||
|
state it touches. The grouping is **subjective** ("Subscription lifecycle",
|
||||||
|
"Discount surface", etc.) — used as a starting point for collapsing the
|
||||||
|
file-per-mutation pattern flagged in `agents.md`. Move two related
|
||||||
|
mutations into one file first; if the test surface stays smaller than the
|
||||||
|
two-file version, the grouping is real.
|
||||||
|
|
||||||
|
| File | Concern | State it READS | State it WRITES |
|
||||||
|
| --------------------------------------------------- | -------------------- | ----------------------------- | ---------------------------------------- |
|
||||||
|
| `mutation_items.go` (`mutation_add_item.go` and `mutation_remove_item.go` merged; tests stay in `mutation_add_item_test.go` + `mutation_remove_item_test.go`) | Line item add/remove | `state.Items` | `state.Items`, `state.UpdatedAt` |
|
||||||
|
| `mutation_change_quantity.go` | Line item quantity | `state.Items[idx]` | `state.Items[idx]`, `state.UpdatedAt` |
|
||||||
|
| `mutation_clear_cart.go` | Wholesale clear | — | `state.Items`, `state.Vouchers`, `state.UpdatedAt` |
|
||||||
|
| `mutation_set_user_id.go` | Ownership | — | `state.UserID`, `state.UpdatedAt` |
|
||||||
|
| `mutation_set_cart_type.go` (+ test) | Cart type | — | `state.Type`, `state.UpdatedAt` |
|
||||||
|
| `mutation_add_voucher.go` | Discount surface | `state.Vouchers` | `state.Vouchers`, `state.LineTotals` (re-eval), `state.UpdatedAt` |
|
||||||
|
| `mutation_set_custom_fields.go` (+ tests) | Custom K/V | `state.CustomFields` | `state.CustomFields`, `state.UpdatedAt` |
|
||||||
|
| `mutation_set_recovery_contact.go` (+ tests) | Recovery | — | `state.Recovery`, `state.UpdatedAt` |
|
||||||
|
| `mutation_subscription_added.go` | Subscription | `state.Items` | `state.Subscriptions`, `state.UpdatedAt` |
|
||||||
|
| `mutation_upsert_subscriptiondetails.go` | Subscription details | `state.Subscriptions` | `state.Subscriptions`, `state.UpdatedAt` |
|
||||||
|
| `mutation_markings.go` (`mutation_line_item_marking.go` and `mutation_remove_line_item_marking.go` merged) | Markings apply/remove | `state.Items[idx].Markings` | `state.Items[idx].Markings`, `state.UpdatedAt` |
|
||||||
|
|
||||||
|
## Delete-test quick check
|
||||||
|
|
||||||
|
For any proposed grouping, ask: does deleting the merged file concentrate
|
||||||
|
complexity, or just move lines? For markings — yes, the shared logic
|
||||||
|
(cascade to pricing) makes the merge worthwhile, and the merge is now done
|
||||||
|
(see below). For `voucher` vs `custom_fields` — no, those read different
|
||||||
|
state and have different error contracts; keep separate.
|
||||||
|
|
||||||
|
### What is already merged
|
||||||
|
|
||||||
|
`pkg/cart/mutation_items.go` (added 2025-07) — AddItem and RemoveItem
|
||||||
|
both touch `state.Items` and share the `ErrPaymentInProgress`,
|
||||||
|
`decodeExtra`, and `getOrgPrice` helpers. Merge passed the delete-test
|
||||||
|
checks: the receiver `*CartMutationContext`, same grain (`*CartGrain`),
|
||||||
|
same mutation-registry dispatch. Both `mutation_*_test.go` files remain
|
||||||
|
because they assert specific method behaviour, not generic package
|
||||||
|
behaviour. Verified: `go test -count=1 ./pkg/cart/...` clean (voucher test
|
||||||
|
which references `ErrPaymentInProgress` still green).
|
||||||
|
|
||||||
|
#### Table corrections when merging
|
||||||
|
|
||||||
|
When the two source rows collapsed into one, two corrections landed in
|
||||||
|
the merge:
|
||||||
|
|
||||||
|
- AddItem's old READS column listed `state.Vouchers (for stacking)`.
|
||||||
|
That clause was **incorrect** — voucher stacking is `AddVoucher`'s
|
||||||
|
concern, not AddItem's. The merge drops the clause because reading
|
||||||
|
`mutation_add_item.go` confirms AddItem only reads `state.Items`.
|
||||||
|
This is a documentation correction, not a behaviour change.
|
||||||
|
- Both `State it READS / WRITES` columns originally had `(both)`
|
||||||
|
annotations to remind that two distinct mutations were sharing the
|
||||||
|
row. After collapse the row already says `Line item add/remove`, so
|
||||||
|
the `(both)` markers are noise — dropped.
|
||||||
|
|
||||||
|
If a future merge lands, follow the same pattern: read the actual source
|
||||||
|
to verify the row's claimed state surface, and prune label-noise before
|
||||||
|
compressing two rows into one.
|
||||||
|
|
||||||
|
`pkg/cart/mutation_markings.go` (added 2025-07) — LineItemMarking and
|
||||||
|
RemoveLineItemMarking both touch `state.Items[idx].Markings` and share
|
||||||
|
the same item-lookup loop (find by ID, mutate `Marking` field). Merge
|
||||||
|
passed the delete-test: the shared write surface and identical error
|
||||||
|
contract ("item with ID %d not found") make the grouping real. No test
|
||||||
|
files existed for either mutation, so the test surface is unchanged.
|
||||||
|
Verified: `go build ./pkg/cart/...` and `go test -count=1 ./pkg/cart/...`
|
||||||
|
clean.
|
||||||
|
|
||||||
|
### NOT to merge next
|
||||||
|
|
||||||
|
`mutation_change_quantity.go` looks like an obvious candidate for the next
|
||||||
|
merge (same receiver, same grain, same `state.Items` write surface). It
|
||||||
|
is **not** — quantity has a partial-line-drop arithmetic contract that
|
||||||
|
add/remove do not:
|
||||||
|
|
||||||
|
- `AddItem` always merges or appends; never sets quantity down.
|
||||||
|
- `RemoveItem` always removes whole lines; never decrements quantity.
|
||||||
|
- `ChangeQuantity` may drop a line entirely if quantity reaches 0.
|
||||||
|
|
||||||
|
If you merge `ChangeQuantity` first, doing the same delete-test check
|
||||||
|
yields: deleting the merged file leaves the partial-line arithmetic
|
||||||
|
logic stranded in `Apply`, which IS a real concentration (good). But
|
||||||
|
the inverse: if `AddItem` later grows a "decrement" edge case, the
|
||||||
|
merged file will balloon and a re-split becomes expensive. Either
|
||||||
|
keep `ChangeQuantity` separate, OR merge all three with the merge
|
||||||
|
scoped to a "line item lifecycle" package and the partial-line
|
||||||
|
arithmetic under a clearly-named helper.
|
||||||
|
|
||||||
|
## How this maps onto a refactor
|
||||||
|
|
||||||
|
1. Pick a row with shared logic (markings, subscription details).
|
||||||
|
2. Move related mutations + their tests into one file under
|
||||||
|
`pkg/cart/<concern>.go`.
|
||||||
|
3. Re-run `go test ./pkg/cart/...` — same assertions, smaller surface.
|
||||||
|
4. Update this table to reflect the new file layout.
|
||||||
+46
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,6 +86,25 @@ type Notice struct {
|
|||||||
Code *string `json:"code,omitempty"`
|
Code *string `json:"code,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PushToken is the Go-level mirror of cart_messages.PushToken: a generic,
|
||||||
|
// provider-agnostic push-delivery target. Stored on the grain so the
|
||||||
|
// abandoned-cart recovery scanner can hand it to a notifier without
|
||||||
|
// re-decoding proto messages; the wire shape lives in proto/cart.proto.
|
||||||
|
//
|
||||||
|
// SECURITY: the raw Token persists verbatim to the per-pod event log
|
||||||
|
// (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read
|
||||||
|
// access to the cart service's data dir — PVC snapshots, debug dumps, log
|
||||||
|
// archival — can extract device handles. A future hardening pass should
|
||||||
|
// either hash-on-write (hash.compareAtLaunch) for stateless matching or
|
||||||
|
// encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real
|
||||||
|
// notifier should treat the stored token as opaque, fetch any canonical
|
||||||
|
// canonicalised handle from the input side (frontend/web SDK), and use
|
||||||
|
// what's here only for routing.
|
||||||
|
type PushToken struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
type CartPaymentStatus string
|
type CartPaymentStatus string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -136,11 +156,24 @@ type CartGrain struct {
|
|||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
Version uint `json:"version"`
|
Version uint `json:"version"`
|
||||||
InventoryReserved bool `json:"inventoryReserved"`
|
InventoryReserved bool `json:"inventoryReserved"`
|
||||||
|
Type cart_messages.CartType `json:"type,omitempty"`
|
||||||
Id CartId `json:"id"`
|
Id CartId `json:"id"`
|
||||||
Items []*CartItem `json:"items"`
|
Items []*CartItem `json:"items"`
|
||||||
TotalPrice *Price `json:"totalPrice"`
|
TotalPrice *Price `json:"totalPrice"`
|
||||||
TotalDiscount *Price `json:"totalDiscount"`
|
TotalDiscount *Price `json:"totalDiscount"`
|
||||||
Processing bool `json:"processing"`
|
// EvaluatedItems is the per-line promotion breakdown — see
|
||||||
|
// EvaluatedItem for shape and semantics. Populated by the canonical
|
||||||
|
// promotion pipeline (pkg/promotions.PromotionService.EvaluateAndApply)
|
||||||
|
// after every successful mutation, alongside TotalDiscount and
|
||||||
|
// AppliedPromotions, so any consumer of the grain (UCP cart response,
|
||||||
|
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) sees the
|
||||||
|
// per-line discount/effective totals without re-running the engine at
|
||||||
|
// response time. Mirrors the same shape /promotions/evaluate-with-cart
|
||||||
|
// has returned since the breakdown feature was introduced; deliberately
|
||||||
|
// persisted via the event-log JSON block so we don't have to re-derive
|
||||||
|
// it on every read path (same precedent as TotalPrice/TotalDiscount).
|
||||||
|
EvaluatedItems []EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||||
|
Processing bool `json:"processing"`
|
||||||
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
||||||
OrderReference string `json:"orderReference,omitempty"`
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
|
|
||||||
@@ -149,6 +182,14 @@ type CartGrain struct {
|
|||||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||||
|
|
||||||
|
// Email is the cart's recovery contact address. Set explicitly via the
|
||||||
|
// SetRecoveryContact mutation (independent of the linked profile's email),
|
||||||
|
// so abandoned-cart recovery can fire before login. Empty string = no email.
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
// PushTokens holds every push delivery target the shopper attached to this
|
||||||
|
// cart. Empty list = no push. PUT-style replaced by SetRecoveryContact.
|
||||||
|
PushTokens []PushToken `json:"pushTokens,omitempty"`
|
||||||
|
|
||||||
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
||||||
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
||||||
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
||||||
@@ -298,6 +339,10 @@ func (c *CartGrain) UpdateTotals() {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Type == cart_messages.CartType_WISHLIST || c.Type == cart_messages.CartType_OFFER {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, voucher := range c.Vouchers {
|
for _, voucher := range c.Vouchers {
|
||||||
_, ok := voucher.AppliesTo(c)
|
_, ok := voucher.AppliesTo(c)
|
||||||
voucher.Applied = false
|
voucher.Applied = false
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package cart
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
"git.k6n.net/mats/platform/inventory"
|
"git.k6n.net/mats/platform/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +73,100 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
|
|||||||
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// equalOptional is the canonical nullable-equality primitive used by the
|
||||||
|
// cart mutations when deciding whether two identifier fields (parent/child
|
||||||
|
// links, store ids, anything else nullable) refer to the same thing.
|
||||||
|
//
|
||||||
|
// Returns true when both pointers are nil, when both alias the same
|
||||||
|
// address, or when both are non-nil and dereference to equal values. The
|
||||||
|
// same-address short-circuit is a no-op for correctness but lets a caller
|
||||||
|
// who passes the same variable get back true without traversing the
|
||||||
|
// generic's comparison path.
|
||||||
|
//
|
||||||
|
// One tested primitive covers every *T the cart currently compares —
|
||||||
|
// `*uint32` for ParentId, `*string` for StoreId — and any future
|
||||||
|
// comparable pointer type. Hand-rolled "both nil OR both non-nil and
|
||||||
|
// equal" expressions were duplicated at AddItem's merge site and were a
|
||||||
|
// footgun whenever a new field was added (which axis of the predicate
|
||||||
|
// was the relevant one?). Keeping a single helper makes future merges
|
||||||
|
// safer to copy.
|
||||||
|
func equalOptional[T comparable](a, b *T) bool {
|
||||||
|
if a == b { // both nil OR same address
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return *a == *b
|
||||||
|
}
|
||||||
|
|
||||||
|
// CascadeChildQuantities scales every direct child's quantity
|
||||||
|
// proportionally when a parent's quantity is bumped (ChangeQuantity or
|
||||||
|
// the AddItem-merge path).
|
||||||
|
//
|
||||||
|
// - Ratio: newChildQty = oldChildQty × newParentQty / oldParentQty.
|
||||||
|
// - Floor: scale-down via integer division can yield 0 (e.g. parent
|
||||||
|
// 2 → 1, child qty 1 → (1×1)/2 = 0). We clamp to 1 so a scale-down
|
||||||
|
// never silently deletes an accessory. The mismatch is acceptable —
|
||||||
|
// shipping one extra child is better than dropping one.
|
||||||
|
// - Reservations: each child's reservation is released then re-acquired
|
||||||
|
// at the new quantity if UseReservations is true for that child AND a
|
||||||
|
// prior reservation existed. A failing reservation is logged, not
|
||||||
|
// raised, so a single bad child doesn't abort the whole cascade
|
||||||
|
// (matches the existing removal pattern in mutation_items.go).
|
||||||
|
// - Recursion: descends into grandchildren using the child's own
|
||||||
|
// before/after quantities as the next ratio's input, so deeply
|
||||||
|
// nested accessory trees (parent → child → grandchild) stay in
|
||||||
|
// ratio with the top-level change.
|
||||||
|
//
|
||||||
|
// Caller is responsible for locking any grain mutex and for re-running
|
||||||
|
// UpdateTotals after the cascade has settled.
|
||||||
|
func (c *CartMutationContext) CascadeChildQuantities(
|
||||||
|
ctx context.Context,
|
||||||
|
g *CartGrain,
|
||||||
|
parentLineId uint32,
|
||||||
|
oldParentQty uint16,
|
||||||
|
newParentQty uint16,
|
||||||
|
) {
|
||||||
|
if oldParentQty == 0 || oldParentQty == newParentQty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.ParentId == nil || *it.ParentId != parentLineId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldChildQty := it.Quantity
|
||||||
|
ratio := uint32(newParentQty) * uint32(oldChildQty) / uint32(oldParentQty)
|
||||||
|
newChildQty := uint16(ratio)
|
||||||
|
if newChildQty == 0 {
|
||||||
|
newChildQty = 1
|
||||||
|
}
|
||||||
|
if newChildQty == oldChildQty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(it) && it.ReservationEndTime != nil {
|
||||||
|
if err := c.ReleaseItem(ctx, g.Id, it.Sku, it.StoreId); err != nil {
|
||||||
|
log.Printf("CascadeChildQuantities: failed to release %s: %v", it.Sku, err)
|
||||||
|
}
|
||||||
|
endTime, err := c.ReserveItem(ctx, g.Id, it.Sku, it.StoreId, newChildQty)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("CascadeChildQuantities: failed to reserve %s at qty %d: %v", it.Sku, newChildQty, err)
|
||||||
|
} else if endTime != nil {
|
||||||
|
it.ReservationEndTime = endTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it.Quantity = newChildQty
|
||||||
|
// Descend so grandchildren track this child's ratio.
|
||||||
|
c.CascadeChildQuantities(ctx, g, it.Id, oldChildQty, newChildQty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
||||||
|
|
||||||
reg := actor.NewMutationRegistry()
|
reg := actor.NewMutationRegistry()
|
||||||
@@ -87,6 +183,8 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
|
|||||||
actor.NewMutation(RemoveLineItemMarking),
|
actor.NewMutation(RemoveLineItemMarking),
|
||||||
actor.NewMutation(SetLineItemCustomFields),
|
actor.NewMutation(SetLineItemCustomFields),
|
||||||
actor.NewMutation(SubscriptionAdded),
|
actor.NewMutation(SubscriptionAdded),
|
||||||
|
actor.NewMutation(context.SetCartType),
|
||||||
|
actor.NewMutation(SetRecoveryContact),
|
||||||
// actor.NewMutation(SubscriptionRemoved),
|
// actor.NewMutation(SubscriptionRemoved),
|
||||||
)
|
)
|
||||||
return reg
|
return reg
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// equalOptional is the nullable-equality primitive shared by the AddItem
|
||||||
|
// merge site and any future field-comparison. Two call sites use it today
|
||||||
|
// with two distinct types — *uint32 (ParentId) and *string (StoreId) — so
|
||||||
|
// the test pins both via generic instantiation.
|
||||||
|
func TestEqualOptional(t *testing.T) {
|
||||||
|
t.Run("both nil is equal", func(t *testing.T) {
|
||||||
|
if !equalOptional[string](nil, nil) {
|
||||||
|
t.Error("both nil should be equal")
|
||||||
|
}
|
||||||
|
if !equalOptional[uint32](nil, nil) {
|
||||||
|
t.Error("both nil should be equal (uint32)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("same address alias is equal", func(t *testing.T) {
|
||||||
|
s := "x"
|
||||||
|
if !equalOptional(&s, &s) {
|
||||||
|
t.Error("same-address *string should be equal")
|
||||||
|
}
|
||||||
|
v := uint32(5)
|
||||||
|
if !equalOptional(&v, &v) {
|
||||||
|
t.Error("same-address *uint32 should be equal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("distinct addresses with equal values are equal", func(t *testing.T) {
|
||||||
|
a, b := "x", "x"
|
||||||
|
if !equalOptional(&a, &b) {
|
||||||
|
t.Error("equal strings at distinct addresses should be equal")
|
||||||
|
}
|
||||||
|
u, w := uint32(5), uint32(5)
|
||||||
|
if !equalOptional(&u, &w) {
|
||||||
|
t.Error("equal uint32 at distinct addresses should be equal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("one nil, one set is not equal", func(t *testing.T) {
|
||||||
|
a := "x"
|
||||||
|
if equalOptional(&a, nil) {
|
||||||
|
t.Error("non-nil vs nil should differ (string)")
|
||||||
|
}
|
||||||
|
if equalOptional[string](nil, &a) {
|
||||||
|
t.Error("nil vs non-nil should differ (string)")
|
||||||
|
}
|
||||||
|
u := uint32(5)
|
||||||
|
if equalOptional(&u, nil) {
|
||||||
|
t.Error("non-nil vs nil should differ (uint32)")
|
||||||
|
}
|
||||||
|
if equalOptional[uint32](nil, &u) {
|
||||||
|
t.Error("nil vs non-nil should differ (uint32)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("differing values are not equal", func(t *testing.T) {
|
||||||
|
a, b := "x", "y"
|
||||||
|
if equalOptional(&a, &b) {
|
||||||
|
t.Error("differing strings should differ")
|
||||||
|
}
|
||||||
|
u, w := uint32(5), uint32(6)
|
||||||
|
if equalOptional(&u, &w) {
|
||||||
|
t.Error("differing uint32 should differ")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
// EvaluatedItem is one line in the per-line promotion breakdown exposed
|
||||||
|
// alongside a cart. It mirrors the line that was evaluated (sku,
|
||||||
|
// quantity, unit list price, optional OrgPrice for the strikethrough) and
|
||||||
|
// adds the per-line discount the engine applied (orgPrice-based +
|
||||||
|
// promotion-based, additive) plus the resulting effective per-unit and
|
||||||
|
// per-line totals.
|
||||||
|
//
|
||||||
|
// Lives in pkg/cart rather than pkg/promotions so CartGrain can carry the
|
||||||
|
// breakdown as a derived field (populated by the canonical promotion
|
||||||
|
// pipeline that already manages TotalDiscount/AppliedPromotions), and so
|
||||||
|
// every consumer of the grain — UCP cart response, legacy /cart HTTP
|
||||||
|
// handler, cart-mcp, AMQP mutation feed — naturally sees the same shape
|
||||||
|
// without ad-hoc wrappers at each call site. JSON tags are the canonical
|
||||||
|
// EvaluatedItem shape that /promotions/evaluate-with-cart has returned
|
||||||
|
// since the breakdown feature was introduced.
|
||||||
|
//
|
||||||
|
// Distributing the total cart discount down to the line level lets the
|
||||||
|
// storefront render "Item X: 100 kr → 80 kr" without re-doing the math
|
||||||
|
// on the client and lets verifiers confirm which promotion hit which
|
||||||
|
// line (per-line DiscountIncVat is the engine's authoritative answer).
|
||||||
|
type EvaluatedItem struct {
|
||||||
|
Sku string `json:"sku"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Quantity uint16 `json:"qty"`
|
||||||
|
PriceIncVat int64 `json:"priceIncVat"` // per-unit list price
|
||||||
|
OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough)
|
||||||
|
DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre
|
||||||
|
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded)
|
||||||
|
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapEvaluatedItems walks the grain's items and produces the per-line
|
||||||
|
// EvaluatedItem list. The grain's item.Discount carries the TOTAL per-line
|
||||||
|
// discount (orgPrice + promotion — additive, set by UpdateTotals and the
|
||||||
|
// effects' per-line distribution). The effective totals are derived as
|
||||||
|
// (price * qty - discount), clamped to 0 (a promotion that over-discounted
|
||||||
|
// a line shows as free, not negative). The effective per-unit price is the
|
||||||
|
// per-line total divided by quantity, rounded to the nearest öre so the
|
||||||
|
// per-unit display matches the per-line total when multiplied back.
|
||||||
|
//
|
||||||
|
// Returns nil for a nil grain (callers that always pass a non-nil grain
|
||||||
|
// — the typical case — get a non-nil empty slice for an empty cart, which
|
||||||
|
// is the desired behavior for JSON omitempty at the consumer).
|
||||||
|
//
|
||||||
|
// Exported so the canonical promotion pipeline (pkg/promotions.EvaluateAndApply)
|
||||||
|
// and the manual /promotions/evaluate-with-cart preview handler can produce
|
||||||
|
// the same per-line shape.
|
||||||
|
func MapEvaluatedItems(g *CartGrain) []EvaluatedItem {
|
||||||
|
if g == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]EvaluatedItem, 0, len(g.Items))
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
if it.Meta != nil {
|
||||||
|
name = it.Meta.Name
|
||||||
|
}
|
||||||
|
var orgPrice int64
|
||||||
|
if it.OrgPrice != nil {
|
||||||
|
orgPrice = it.OrgPrice.IncVat.Int64()
|
||||||
|
}
|
||||||
|
var discount int64
|
||||||
|
if it.Discount != nil {
|
||||||
|
discount = it.Discount.IncVat.Int64()
|
||||||
|
}
|
||||||
|
priceRow := it.Price.IncVat.Int64() * int64(it.Quantity)
|
||||||
|
effTotal := priceRow - discount
|
||||||
|
if effTotal < 0 {
|
||||||
|
effTotal = 0
|
||||||
|
}
|
||||||
|
var effUnit int64
|
||||||
|
if it.Quantity > 0 {
|
||||||
|
// Nearest-öre rounding so per-unit * qty matches the
|
||||||
|
// per-line total (within 1 öre either way). Half-up:
|
||||||
|
// (effTotal + qty/2) / qty.
|
||||||
|
effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity)
|
||||||
|
}
|
||||||
|
out = append(out, EvaluatedItem{
|
||||||
|
Sku: it.Sku,
|
||||||
|
Name: name,
|
||||||
|
Quantity: it.Quantity,
|
||||||
|
PriceIncVat: it.Price.IncVat.Int64(),
|
||||||
|
OrgPriceIncVat: orgPrice,
|
||||||
|
DiscountIncVat: discount,
|
||||||
|
EffectivePriceIncVat: effUnit,
|
||||||
|
EffectiveTotalIncVat: effTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
|
|
||||||
// payloads are logged and dropped rather than failing the mutation.
|
|
||||||
func decodeExtra(b []byte) map[string]json.RawMessage {
|
|
||||||
if len(b) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m := map[string]json.RawMessage{}
|
|
||||||
if err := json.Unmarshal(b, &m); err != nil {
|
|
||||||
log.Printf("AddItem: invalid extra_json: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// mutation_add_item.go
|
|
||||||
//
|
|
||||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
|
||||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Validates quantity > 0
|
|
||||||
// - If an item with the same item id (ItemId) exists -> increases quantity
|
|
||||||
// - Else creates a new CartItem with computed tax amounts
|
|
||||||
// - Totals recalculated automatically via WithTotals()
|
|
||||||
//
|
|
||||||
// Item identity is the catalog item id (ItemId), not the SKU: the product
|
|
||||||
// service is looked up by id and the returned SKU is reference-only.
|
|
||||||
//
|
|
||||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
|
||||||
// must keep this handler in sync.
|
|
||||||
var ErrPaymentInProgress = errors.New("payment in progress")
|
|
||||||
|
|
||||||
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
|
|
||||||
ctx := context.Background()
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("AddItem: nil payload")
|
|
||||||
}
|
|
||||||
if m.Quantity < 1 {
|
|
||||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with any existing item having the same item id and matching StoreId
|
|
||||||
// (including both nil). Identity is the id; SKU is reference-only.
|
|
||||||
for _, existing := range g.Items {
|
|
||||||
if existing.ItemId != m.ItemId {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
|
||||||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
|
|
||||||
if !sameStore {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c.UseReservations(existing) {
|
|
||||||
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
|
|
||||||
log.Printf("failed to release item %d: %v", existing.Id, err)
|
|
||||||
}
|
|
||||||
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
existing.ReservationEndTime = endTime
|
|
||||||
}
|
|
||||||
existing.Quantity += uint16(m.Quantity)
|
|
||||||
existing.Stock = uint16(m.Stock)
|
|
||||||
existing.InventoryTracked = m.InventoryTracked
|
|
||||||
existing.DropShip = m.DropShip
|
|
||||||
// If existing had nil store but new has one, adopt it.
|
|
||||||
if existing.StoreId == nil && m.StoreId != nil {
|
|
||||||
existing.StoreId = m.StoreId
|
|
||||||
}
|
|
||||||
// Refresh dynamic product data with the latest payload.
|
|
||||||
if extra := decodeExtra(m.ExtraJson); extra != nil {
|
|
||||||
existing.Extra = extra
|
|
||||||
}
|
|
||||||
// Replace custom fields when provided on the re-add.
|
|
||||||
if len(m.CustomFields) > 0 {
|
|
||||||
existing.CustomFields = m.CustomFields
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
g.mu.Lock()
|
|
||||||
defer g.mu.Unlock()
|
|
||||||
|
|
||||||
g.lastItemId++
|
|
||||||
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
|
|
||||||
// platform scale; flows straight through, no conversion.
|
|
||||||
rateBp := 2500
|
|
||||||
if m.Tax > 0 {
|
|
||||||
rateBp = int(m.Tax)
|
|
||||||
}
|
|
||||||
|
|
||||||
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
|
|
||||||
|
|
||||||
needsReservation := true
|
|
||||||
if m.ReservationEndTime != nil {
|
|
||||||
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
cartItem := &CartItem{
|
|
||||||
Id: g.lastItemId,
|
|
||||||
ItemId: uint32(m.ItemId),
|
|
||||||
Quantity: uint16(m.Quantity),
|
|
||||||
Sku: m.Sku,
|
|
||||||
Tax: rateBp,
|
|
||||||
Meta: &ItemMeta{
|
|
||||||
Name: m.Name,
|
|
||||||
Image: m.Image,
|
|
||||||
Brand: m.Brand,
|
|
||||||
Category: m.Category,
|
|
||||||
Category2: m.Category2,
|
|
||||||
Category3: m.Category3,
|
|
||||||
Category4: m.Category4,
|
|
||||||
Category5: m.Category5,
|
|
||||||
Outlet: m.Outlet,
|
|
||||||
SellerName: m.SellerName,
|
|
||||||
},
|
|
||||||
SellerId: m.SellerId,
|
|
||||||
Cgm: m.Cgm,
|
|
||||||
SaleStatus: m.SaleStatus,
|
|
||||||
ParentId: m.ParentId,
|
|
||||||
|
|
||||||
Price: *pricePerItem,
|
|
||||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
|
||||||
|
|
||||||
Stock: uint16(m.Stock),
|
|
||||||
Disclaimer: m.Disclaimer,
|
|
||||||
|
|
||||||
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
|
|
||||||
ArticleType: m.ArticleType,
|
|
||||||
|
|
||||||
StoreId: m.StoreId,
|
|
||||||
|
|
||||||
Extra: decodeExtra(m.ExtraJson),
|
|
||||||
CustomFields: m.CustomFields,
|
|
||||||
InventoryTracked: m.InventoryTracked,
|
|
||||||
DropShip: m.DropShip,
|
|
||||||
}
|
|
||||||
|
|
||||||
if needsReservation && c.UseReservations(cartItem) {
|
|
||||||
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if endTime != nil {
|
|
||||||
m.ReservationEndTime = timestamppb.New(*endTime)
|
|
||||||
t := m.ReservationEndTime.AsTime()
|
|
||||||
cartItem.ReservationEndTime = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.Items = append(g.Items, cartItem)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOrgPrice(orgPrice int64, rateBp int) *Price {
|
|
||||||
if orgPrice <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return NewPriceFromIncVat(orgPrice, rateBp)
|
|
||||||
}
|
|
||||||
@@ -38,3 +38,155 @@ func TestAddItem_MergesByItemIdNotSku(t *testing.T) {
|
|||||||
t.Fatalf("items = %d, want 2", len(g.Items))
|
t.Fatalf("items = %d, want 2", len(g.Items))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-adding the same parent (same ItemId + StoreId, no ParentId on the
|
||||||
|
// re-add itself) merges the quantity, and the proportional cascade resizes
|
||||||
|
// every direct child to match the new parent qty so a "+1 drill" leaves
|
||||||
|
// the bundle internally consistent.
|
||||||
|
func TestAddItem_MergeCascadesToChildren(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("add parent: %v", err)
|
||||||
|
}
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("add child: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-add the same parent — same ItemId, no ParentId on the re-add itself.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("re-add parent: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent qty 1 -> 2; child qty 1 -> 2 via cascade.
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("line %s qty = %d, want 2 (merge cascade)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item identity for merge purposes includes ParentId: a standalone root
|
||||||
|
// line (ParentId == nil) and a child line (ParentId == &someParentLine) that
|
||||||
|
// happen to share an ItemId are different roles and MUST stay as distinct
|
||||||
|
// lines. Merging would silently turn an accessory into a +N on the root
|
||||||
|
// (or vice versa), corrupting the parent-child link.
|
||||||
|
func TestAddItem_DoesNotMergeWhenParentIdDiffers(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Standalone root (no ParentId).
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("add standalone: %v", err)
|
||||||
|
}
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
|
||||||
|
// Same ItemId+StoreId but with ParentId set — must create a NEW line,
|
||||||
|
// not bump the standalone's qty.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("add child-form: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Items) != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2 (different ParentId => distinct lines)", len(g.Items))
|
||||||
|
}
|
||||||
|
// Standalone untouched at qty 1; child-form is its own line at qty 1.
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("line %d (parentId=%v) qty = %d, want 1 (no merge)", it.Id, it.ParentId, it.Quantity)
|
||||||
|
}
|
||||||
|
// One of them has nil ParentId and the other has &parentLine — guard
|
||||||
|
// against an accidental merge ever flipping both to non-nil.
|
||||||
|
}
|
||||||
|
var seenNil, seenSet bool
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.ParentId == nil {
|
||||||
|
seenNil = true
|
||||||
|
} else if *it.ParentId == parentLine {
|
||||||
|
seenSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seenNil || !seenSet {
|
||||||
|
t.Errorf("expected one line with nil ParentId and one with &parentLine; got nil=%v set=%v", seenNil, seenSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positive control: when BOTH sides have ParentId set to the SAME parent
|
||||||
|
// line, the merge proceeds (this guards the new sameParent guard against
|
||||||
|
// accidentally rejecting a legitimate "re-add same child of same parent"
|
||||||
|
// case — e.g. an idempotent retry from a flaky request).
|
||||||
|
func TestAddItem_MergesWhenBothParentIdMatch(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||||
|
t.Fatalf("add parent: %v", err)
|
||||||
|
}
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
|
||||||
|
// First child: ItemId 200 under parent.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("add child: %v", err)
|
||||||
|
}
|
||||||
|
// Re-add the SAME child (same ItemId+StoreId+ParentId) — must merge into
|
||||||
|
// qty 2, not create a second accessory line.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||||
|
t.Fatalf("re-add child: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Items) != 2 {
|
||||||
|
t.Fatalf("items = %d, want 2 (parent + merged child)", len(g.Items))
|
||||||
|
}
|
||||||
|
for _, it := range g.Items {
|
||||||
|
switch it.Id {
|
||||||
|
case parentLine:
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("parent qty = %d, want 1 (untouched)", it.Quantity)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("child qty = %d, want 2 (merged on same-PARENT re-add)", it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same ItemId+StoreId+both with ParentId set to *different* parent lines
|
||||||
|
// must also stay distinct — children belong to their own parent.
|
||||||
|
func TestAddItem_DoesNotMergeAcrossDifferentParentLines(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P1", Quantity: 1, Price: 500}); err != nil {
|
||||||
|
t.Fatalf("add parent A: %v", err)
|
||||||
|
}
|
||||||
|
parentA := g.Items[0].Id
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "P2", Quantity: 1, Price: 500}); err != nil {
|
||||||
|
t.Fatalf("add parent B: %v", err)
|
||||||
|
}
|
||||||
|
parentB := g.Items[1].Id
|
||||||
|
|
||||||
|
// Two children of different parents sharing an ItemId — distinct lines.
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentA}); err != nil {
|
||||||
|
t.Fatalf("add child of A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentB}); err != nil {
|
||||||
|
t.Fatalf("add child of B: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 parent lines + 2 child lines = 4 total; no merges across.
|
||||||
|
if len(g.Items) != 4 {
|
||||||
|
t.Errorf("items = %d, want 4 (parents A,B + their distinct children)", len(g.Items))
|
||||||
|
}
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("line %d qty = %d, want 1 (no accidental merge)", it.Id, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"git.k6n.net/mats/platform/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockReservationPolicy struct {
|
||||||
|
reserveCount int
|
||||||
|
releaseCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReservationPolicy) Reserve(ctx context.Context, req inventory.CartReserveRequest) error {
|
||||||
|
m.reserveCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReservationPolicy) Release(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID, cartID inventory.CartID) error {
|
||||||
|
m.releaseCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockReservationPolicy) Available(ctx context.Context, sku inventory.SKU, locationID inventory.LocationID) (int64, error) {
|
||||||
|
return 100, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCartType_TransitionsAndReservations(t *testing.T) {
|
||||||
|
mockPolicy := &mockReservationPolicy{}
|
||||||
|
mutationCtx := NewCartMutationContext(mockPolicy)
|
||||||
|
reg := NewCartMultationRegistry(mutationCtx)
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. Initially REGULAR, adding item should trigger reservation
|
||||||
|
g.Type = cart_messages.CartType_REGULAR
|
||||||
|
_, err := reg.Apply(ctx, g, &cart_messages.AddItem{
|
||||||
|
ItemId: 101,
|
||||||
|
Sku: "SKU-1",
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 1000,
|
||||||
|
InventoryTracked: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem (REGULAR) failed: %v", err)
|
||||||
|
}
|
||||||
|
if mockPolicy.reserveCount != 1 {
|
||||||
|
t.Errorf("Expected 1 reserve call, got %d", mockPolicy.reserveCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Set type to WISHLIST. This should release active reservations.
|
||||||
|
_, err = reg.Apply(ctx, g, &cart_messages.SetCartType{
|
||||||
|
Type: cart_messages.CartType_WISHLIST,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetCartType (WISHLIST) failed: %v", err)
|
||||||
|
}
|
||||||
|
if g.Type != cart_messages.CartType_WISHLIST {
|
||||||
|
t.Errorf("Expected type to be WISHLIST, got %v", g.Type)
|
||||||
|
}
|
||||||
|
if mockPolicy.releaseCount != 1 {
|
||||||
|
t.Errorf("Expected 1 release call during transition, got %d", mockPolicy.releaseCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. AddItem in WISHLIST should NOT trigger reservation
|
||||||
|
_, err = reg.Apply(ctx, g, &cart_messages.AddItem{
|
||||||
|
ItemId: 102,
|
||||||
|
Sku: "SKU-2",
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 2000,
|
||||||
|
InventoryTracked: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddItem (WISHLIST) failed: %v", err)
|
||||||
|
}
|
||||||
|
if mockPolicy.reserveCount != 1 { // Should still be 1 from step 1
|
||||||
|
t.Errorf("Expected reserve count to remain 1, got %d", mockPolicy.reserveCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Verify simple price sum is updated, but vouchers are not applied
|
||||||
|
g.Vouchers = append(g.Vouchers, &Voucher{
|
||||||
|
Code: "DISCOUNT",
|
||||||
|
Value: 500,
|
||||||
|
Applied: false,
|
||||||
|
})
|
||||||
|
g.UpdateTotals()
|
||||||
|
// Total price should be sum of 1000 + 2000 = 3000
|
||||||
|
if g.TotalPrice.IncVat != 3000 {
|
||||||
|
t.Errorf("Expected total price 3000, got %d", g.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
if g.Vouchers[0].Applied {
|
||||||
|
t.Errorf("Expected voucher to NOT be applied in WISHLIST")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mutation_change_quantity.go
|
// mutation_change_quantity.go
|
||||||
@@ -15,8 +14,11 @@ import (
|
|||||||
//
|
//
|
||||||
// Behavior:
|
// Behavior:
|
||||||
// - Locates an item by its cart-local line item Id (not source item_id).
|
// - Locates an item by its cart-local line item Id (not source item_id).
|
||||||
// - If requested quantity <= 0 the line is removed.
|
// - If requested quantity <= 0 the line AND any descendants are
|
||||||
// - Otherwise the line's Quantity field is updated.
|
// removed via the shared RemoveItem cascade (transitive parent→child).
|
||||||
|
// - Otherwise the line's Quantity is updated and direct children are
|
||||||
|
// rescaled proportionally so a "2 of this + 2 of each accessory"
|
||||||
|
// bundle stays internally consistent when the parent becomes 3.
|
||||||
// - Totals are recalculated (WithTotals).
|
// - Totals are recalculated (WithTotals).
|
||||||
//
|
//
|
||||||
// Error handling:
|
// Error handling:
|
||||||
@@ -29,7 +31,7 @@ import (
|
|||||||
// (If strict locking is required around every mutation, wrap logic in
|
// (If strict locking is required around every mutation, wrap logic in
|
||||||
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
// an explicit g.mu.Lock()/Unlock(), but current model mirrors prior code.)
|
||||||
|
|
||||||
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQuantity) error {
|
func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *cart_messages.ChangeQuantity) error {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return fmt.Errorf("ChangeQuantity: nil payload")
|
return fmt.Errorf("ChangeQuantity: nil payload")
|
||||||
}
|
}
|
||||||
@@ -48,23 +50,18 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
|
|||||||
}
|
}
|
||||||
|
|
||||||
if m.Quantity <= 0 {
|
if m.Quantity <= 0 {
|
||||||
// Remove the item
|
// Setting a parent line to 0 means "remove it AND its accessories".
|
||||||
itemToRemove := g.Items[foundIndex]
|
// Delegate to RemoveItem so the cascade logic in one place stays
|
||||||
if itemToRemove.ReservationEndTime != nil && itemToRemove.ReservationEndTime.After(time.Now()) {
|
// the source of truth for descendant cleanup + reservation
|
||||||
err := c.ReleaseItem(ctx, g.Id, itemToRemove.Sku, itemToRemove.StoreId)
|
// release — otherwise we'd duplicate the transitive cascade here.
|
||||||
if err != nil {
|
return c.RemoveItem(g, &cart_messages.RemoveItem{Id: m.Id})
|
||||||
log.Printf("unable to release reservation for %s in location: %v", itemToRemove.Sku, itemToRemove.StoreId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
g.Items = append(g.Items[:foundIndex], g.Items[foundIndex+1:]...)
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
item := g.Items[foundIndex]
|
item := g.Items[foundIndex]
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
return fmt.Errorf("ChangeQuantity: item id %d not found", m.Id)
|
||||||
}
|
}
|
||||||
if c.UseReservations(item) {
|
oldQty := item.Quantity
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(item) {
|
||||||
if item.ReservationEndTime != nil {
|
if item.ReservationEndTime != nil {
|
||||||
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
err := c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,6 +76,14 @@ func (c *CartMutationContext) ChangeQuantity(g *CartGrain, m *messages.ChangeQua
|
|||||||
item.ReservationEndTime = endTime
|
item.ReservationEndTime = endTime
|
||||||
}
|
}
|
||||||
item.Quantity = uint16(m.Quantity)
|
item.Quantity = uint16(m.Quantity)
|
||||||
|
// Rescale any direct child lines proportionally so accessories stay
|
||||||
|
// in sync with their parent. Descends recursively into grandchildren
|
||||||
|
// via CascadeChildQuantities itself. Skipped when the target itself
|
||||||
|
// is a child line — direct qty edits on a child are treated as
|
||||||
|
// explicit user intent and don't propagate further.
|
||||||
|
if item.ParentId == nil {
|
||||||
|
c.CascadeChildQuantities(ctx, g, item.Id, oldQty, item.Quantity)
|
||||||
|
}
|
||||||
g.UpdateTotals()
|
g.UpdateTotals()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parent qty 1 → 2 resizes every direct child proportionally. Children all
|
||||||
|
// start at qty 1 so the proportional factor of 2 doubles them.
|
||||||
|
func TestChangeQuantity_ParentCascadesDoublingChildren(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items[0].Quantity != 2 {
|
||||||
|
t.Errorf("parent qty = %d, want 2", g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Id == parentLine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("child %s qty = %d, want 2 (cascaded from parent)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent qty 2 → 3 with children at qty 2 → 3 (factor 3/2 = 1.5, integer math).
|
||||||
|
func TestChangeQuantity_ParentCascadesNonIntegerRatio(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 2, Price: 100, ParentId: &parentLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 3}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parent: 2 -> 3 (direct set), child: 2 * 3 / 2 = 3
|
||||||
|
if g.Items[0].Quantity != 3 {
|
||||||
|
t.Errorf("parent qty = %d, want 3", g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
if g.Items[1].Quantity != 3 {
|
||||||
|
t.Errorf("child qty = %d, want 3 (proportional scale)", g.Items[1].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale-down that would round to 0 (parent 2 → 1, child qty 1 → (1*1)/2 = 0)
|
||||||
|
// must clamp to 1 so the child is never silently dropped by integer math.
|
||||||
|
func TestChangeQuantity_ParentScaleDownFloorsAtOne(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 2, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 1}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Items[0].Quantity != 1 {
|
||||||
|
t.Errorf("parent qty = %d, want 1", g.Items[0].Quantity)
|
||||||
|
}
|
||||||
|
if g.Items[1].Quantity != 1 {
|
||||||
|
t.Errorf("child qty = %d, want 1 (floor; integer math dropped to 0 without clamp)", g.Items[1].Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setting parent qty to 0 delegates to RemoveItem so the existing parent→
|
||||||
|
// child cascade takes over. Unrelated lines are left untouched.
|
||||||
|
func TestChangeQuantity_ZeroCascadesRemoval(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 200, Sku: "OTHER", Quantity: 1, Price: 500})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 0}); err != nil {
|
||||||
|
t.Fatalf("change qty 0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (only OTHER survives)", len(g.Items))
|
||||||
|
}
|
||||||
|
if g.Items[0].Sku != "OTHER" {
|
||||||
|
t.Errorf("remaining sku = %q, want OTHER", g.Items[0].Sku)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing a child's qty directly updates only that child — siblings and
|
||||||
|
// the parent are untouched. (The UI no longer exposes this control, but
|
||||||
|
// the HTTP surface still allows it for tools & direct API calls.)
|
||||||
|
func TestChangeQuantity_ChildDoesNotCascade(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "C2", Quantity: 1, Price: 200, ParentId: &parentLine})
|
||||||
|
targetChild := g.Items[1].Id
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: targetChild, Quantity: 3}); err != nil {
|
||||||
|
t.Fatalf("change child qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, it := range g.Items {
|
||||||
|
switch it.Id {
|
||||||
|
case parentLine:
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("parent qty = %d, want 1 (untouched on child edit)", it.Quantity)
|
||||||
|
}
|
||||||
|
case targetChild:
|
||||||
|
if it.Quantity != 3 {
|
||||||
|
t.Errorf("target child qty = %d, want 3", it.Quantity)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Sibling
|
||||||
|
if it.Quantity != 1 {
|
||||||
|
t.Errorf("sibling %s qty = %d, want 1 (untouched on child edit)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade descends into grandchildren so deeply nested accessory trees
|
||||||
|
// remain internally consistent with the top-level parent change.
|
||||||
|
func TestChangeQuantity_NestedChildren(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000})
|
||||||
|
parentLine := g.Items[0].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine})
|
||||||
|
childLine := g.Items[1].Id
|
||||||
|
mustApply(t, reg, g, &cart_messages.AddItem{ItemId: 102, Sku: "G1", Quantity: 1, Price: 50, ParentId: &childLine})
|
||||||
|
|
||||||
|
if _, err := reg.Apply(ctx, g, &cart_messages.ChangeQuantity{Id: parentLine, Quantity: 2}); err != nil {
|
||||||
|
t.Fatalf("change qty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parent 1 -> 2; child 1 -> 2 (parent -> child cascade); grandchild 1 -> 2 (child -> grandchild cascade)
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Quantity != 2 {
|
||||||
|
t.Errorf("line %s qty = %d, want 2 (nested cascade)", it.Sku, it.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// mutation_items.go
|
||||||
|
//
|
||||||
|
// Single concern file for the cart line-item lifecycle: AddItem +
|
||||||
|
// RemoveItem (and the helpers they share). Pulled out of the per-mutation
|
||||||
|
// file layout as a proof-of-grouping for the pattern in
|
||||||
|
// pkg/cart/MUTATIONS.md. AddItem and RemoveItem are dispatched through the
|
||||||
|
// same MutationRegistry (cart-mutation-helper.go) and operate on the same
|
||||||
|
// *CartGrain, so keeping them adjacent lets future reviewers compare
|
||||||
|
// "what does add do when an item already exists" against "what does
|
||||||
|
// remove do when a parent is removed" without bouncing between files.
|
||||||
|
//
|
||||||
|
// What lives here:
|
||||||
|
//
|
||||||
|
// - AddItem: validation, merge-into-existing by ItemId, reservation
|
||||||
|
// handling, totals.
|
||||||
|
// - RemoveItem: by-line-id removal with transitive parent→child
|
||||||
|
// cascade, reservation release, totals.
|
||||||
|
// - Shared package helpers: decodeExtra (dynamic product extra JSON),
|
||||||
|
// getOrgPrice (origin price helper), ErrPaymentInProgress (used by
|
||||||
|
// mutation_add_voucher.go to refuse stacking during checkout).
|
||||||
|
//
|
||||||
|
// Out of scope here: change_quantity, set_custom_fields, line markings,
|
||||||
|
// subscription details, etc. Those are separate concerns and stay in
|
||||||
|
// their own files until they grow enough behaviour to invite merging.
|
||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPaymentInProgress is returned by mutations that must not proceed
|
||||||
|
// while the cart is mid-payment (currently referenced by
|
||||||
|
// mutation_add_voucher.go to short-circuit adding a voucher mid-checkout).
|
||||||
|
var ErrPaymentInProgress = errors.New("payment in progress")
|
||||||
|
|
||||||
|
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
|
||||||
|
// payloads are logged and dropped rather than failing the mutation.
|
||||||
|
func decodeExtra(b []byte) map[string]json.RawMessage {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m := map[string]json.RawMessage{}
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
log.Printf("AddItem: invalid extra_json: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func getOrgPrice(orgPrice int64, rateBp int) *Price {
|
||||||
|
if orgPrice <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return NewPriceFromIncVat(orgPrice, rateBp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem (formerly mutation_add_item.go).
|
||||||
|
//
|
||||||
|
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||||
|
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||||
|
//
|
||||||
|
// Behaviour:
|
||||||
|
// - Validates quantity > 0
|
||||||
|
// - If an item with the same item id (ItemId) exists -> increases quantity
|
||||||
|
// - Else creates a new CartItem with computed tax amounts
|
||||||
|
// - Totals recalculated automatically via WithTotals()
|
||||||
|
//
|
||||||
|
// Item identity is the catalog item id (ItemId), not the SKU: the product
|
||||||
|
// service is looked up by id and the returned SKU is reference-only.
|
||||||
|
//
|
||||||
|
// NOTE: Any future field additions in messages.AddItem that affect pricing /
|
||||||
|
// tax must keep this handler in sync.
|
||||||
|
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("AddItem: nil payload")
|
||||||
|
}
|
||||||
|
if m.Quantity < 1 {
|
||||||
|
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with any existing item having the same item id, matching
|
||||||
|
// StoreId, and matching ParentId (all three including both nil).
|
||||||
|
// Identity is ItemId; SKU is reference-only.
|
||||||
|
//
|
||||||
|
// ParentId MUST match: a standalone root line (ParentId == nil) and
|
||||||
|
// a child line (ParentId == &someParentLine) that happen to share an
|
||||||
|
// ItemId are different roles in the cart, even though the catalog
|
||||||
|
// item is the same. Merging them would silently convert an accessory
|
||||||
|
// into a +N on the root (or vice versa), corrupting the parent-child
|
||||||
|
// link. Both checks are delegated to equalOptional so the nullable-
|
||||||
|
// equality idiom stays in one place (cart-mutation-helper.go).
|
||||||
|
for _, existing := range g.Items {
|
||||||
|
if existing.ItemId != m.ItemId {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !equalOptional(existing.StoreId, m.StoreId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !equalOptional(existing.ParentId, m.ParentId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(existing) {
|
||||||
|
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
|
||||||
|
log.Printf("failed to release item %d: %v", existing.Id, err)
|
||||||
|
}
|
||||||
|
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing.ReservationEndTime = endTime
|
||||||
|
}
|
||||||
|
oldQty := existing.Quantity
|
||||||
|
existing.Quantity += uint16(m.Quantity)
|
||||||
|
existing.Stock = uint16(m.Stock)
|
||||||
|
existing.InventoryTracked = m.InventoryTracked
|
||||||
|
existing.DropShip = m.DropShip
|
||||||
|
// If existing had nil store but new has one, adopt it.
|
||||||
|
if existing.StoreId == nil && m.StoreId != nil {
|
||||||
|
existing.StoreId = m.StoreId
|
||||||
|
}
|
||||||
|
// Refresh dynamic product data with the latest payload.
|
||||||
|
if extra := decodeExtra(m.ExtraJson); extra != nil {
|
||||||
|
existing.Extra = extra
|
||||||
|
}
|
||||||
|
// Replace custom fields when provided on the re-add.
|
||||||
|
if len(m.CustomFields) > 0 {
|
||||||
|
existing.CustomFields = m.CustomFields
|
||||||
|
}
|
||||||
|
// Re-add grew the parent's qty — rescale direct child quantities
|
||||||
|
// proportionally so a "+1 drill" keeps its accessories consistent.
|
||||||
|
// Skip when the merged line IS itself a child (ParentId != nil):
|
||||||
|
// cascading up the tree isn't a thing — the parent would not know
|
||||||
|
// a child grew without re-running the promotion engine.
|
||||||
|
if existing.ParentId == nil {
|
||||||
|
c.CascadeChildQuantities(ctx, g, existing.Id, oldQty, existing.Quantity)
|
||||||
|
}
|
||||||
|
// Recompute totals so TotalPrice/TotalDiscount/Discount annotations
|
||||||
|
// reflect the merged parent + cascaded children. The non-merge
|
||||||
|
// (new-line) path below also calls UpdateTotals; the merge path
|
||||||
|
// would otherwise drift after a "+1" re-add, especially now that
|
||||||
|
// cascade may have rewritten N child quantities.
|
||||||
|
g.UpdateTotals()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.lastItemId++
|
||||||
|
// m.Tax is the rate in basis points (2500 = 25%, 1250 = 12.5%) — the single
|
||||||
|
// platform scale; flows straight through, no conversion.
|
||||||
|
rateBp := 2500
|
||||||
|
if m.Tax > 0 {
|
||||||
|
rateBp = int(m.Tax)
|
||||||
|
}
|
||||||
|
|
||||||
|
pricePerItem := NewPriceFromIncVat(m.Price, rateBp)
|
||||||
|
|
||||||
|
needsReservation := true
|
||||||
|
if m.ReservationEndTime != nil {
|
||||||
|
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
cartItem := &CartItem{
|
||||||
|
Id: g.lastItemId,
|
||||||
|
ItemId: uint32(m.ItemId),
|
||||||
|
Quantity: uint16(m.Quantity),
|
||||||
|
Sku: m.Sku,
|
||||||
|
Tax: rateBp,
|
||||||
|
Meta: &ItemMeta{
|
||||||
|
Name: m.Name,
|
||||||
|
Image: m.Image,
|
||||||
|
Brand: m.Brand,
|
||||||
|
Category: m.Category,
|
||||||
|
Category2: m.Category2,
|
||||||
|
Category3: m.Category3,
|
||||||
|
Category4: m.Category4,
|
||||||
|
Category5: m.Category5,
|
||||||
|
Outlet: m.Outlet,
|
||||||
|
SellerName: m.SellerName,
|
||||||
|
},
|
||||||
|
SellerId: m.SellerId,
|
||||||
|
Cgm: m.Cgm,
|
||||||
|
SaleStatus: m.SaleStatus,
|
||||||
|
ParentId: m.ParentId,
|
||||||
|
|
||||||
|
Price: *pricePerItem,
|
||||||
|
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||||
|
|
||||||
|
Stock: uint16(m.Stock),
|
||||||
|
Disclaimer: m.Disclaimer,
|
||||||
|
|
||||||
|
OrgPrice: getOrgPrice(m.OrgPrice, rateBp),
|
||||||
|
ArticleType: m.ArticleType,
|
||||||
|
|
||||||
|
StoreId: m.StoreId,
|
||||||
|
|
||||||
|
Extra: decodeExtra(m.ExtraJson),
|
||||||
|
CustomFields: m.CustomFields,
|
||||||
|
InventoryTracked: m.InventoryTracked,
|
||||||
|
DropShip: m.DropShip,
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Type == cart_messages.CartType_REGULAR && needsReservation && c.UseReservations(cartItem) {
|
||||||
|
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if endTime != nil {
|
||||||
|
m.ReservationEndTime = timestamppb.New(*endTime)
|
||||||
|
t := m.ReservationEndTime.AsTime()
|
||||||
|
cartItem.ReservationEndTime = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Items = append(g.Items, cartItem)
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveItem (formerly mutation_remove_item.go).
|
||||||
|
//
|
||||||
|
// Registers the RemoveItem mutation.
|
||||||
|
//
|
||||||
|
// Behaviour:
|
||||||
|
// - Removes the cart line whose local cart line Id == payload.Id
|
||||||
|
// - Cascades: also removes any line whose ParentId points at a removed line
|
||||||
|
// (transitively), so removing a parent removes its child sub-articles
|
||||||
|
// - If no such line exists returns an error
|
||||||
|
// - Releases reservations for every removed line and recalculates totals
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - This removes only the line items; any deliveries referencing a removed
|
||||||
|
// item are NOT automatically adjusted (mirrors prior logic). If future
|
||||||
|
// semantics require pruning delivery.item_ids, extend this handler.
|
||||||
|
// - If multiple lines somehow shared the same Id (should not happen), all
|
||||||
|
// matches are removed — data integrity relies on unique line Ids.
|
||||||
|
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *cart_messages.RemoveItem) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveItem: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID := uint32(m.Id)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.Id == targetID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the target and, transitively, any children pointing at a removed
|
||||||
|
// line. Loops until no further descendants are found (handles nesting).
|
||||||
|
remove := map[uint32]bool{targetID: true}
|
||||||
|
for {
|
||||||
|
grew := false
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
|
||||||
|
remove[it.Id] = true
|
||||||
|
grew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !grew {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kept := g.Items[:0]
|
||||||
|
for _, it := range g.Items {
|
||||||
|
if remove[it.Id] {
|
||||||
|
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
|
||||||
|
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
|
||||||
|
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, it)
|
||||||
|
}
|
||||||
|
g.Items = kept
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = &Marking{
|
|
||||||
Type: req.Type,
|
|
||||||
Text: req.Marking,
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LineItemMarking sets a marking on the line item with the given ID.
|
||||||
|
func LineItemMarking(grain *CartGrain, req *messages.LineItemMarking) error {
|
||||||
|
for i, item := range grain.Items {
|
||||||
|
if item.Id == req.Id {
|
||||||
|
grain.Items[i].Marking = &Marking{
|
||||||
|
Type: req.Type,
|
||||||
|
Text: req.Marking,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("item with ID %d not found", req.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveLineItemMarking clears the marking on the line item with the given ID.
|
||||||
|
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
||||||
|
for i, item := range grain.Items {
|
||||||
|
if item.Id == req.Id {
|
||||||
|
grain.Items[i].Marking = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("item with ID %d not found", req.Id)
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mutation_remove_item.go
|
|
||||||
//
|
|
||||||
// Registers the RemoveItem mutation.
|
|
||||||
//
|
|
||||||
// Behavior:
|
|
||||||
// - Removes the cart line whose local cart line Id == payload.Id
|
|
||||||
// - Cascades: also removes any line whose ParentId points at a removed line
|
|
||||||
// (transitively), so removing a parent removes its child sub-articles
|
|
||||||
// - If no such line exists returns an error
|
|
||||||
// - Releases reservations for every removed line and recalculates totals
|
|
||||||
//
|
|
||||||
// Notes:
|
|
||||||
// - This removes only the line items; any deliveries referencing a removed
|
|
||||||
// item are NOT automatically adjusted (mirrors prior logic). If future
|
|
||||||
// semantics require pruning delivery.item_ids you can extend this handler.
|
|
||||||
// - If multiple lines somehow shared the same Id (should not happen), all
|
|
||||||
// matches are removed—data integrity relies on unique line Ids.
|
|
||||||
|
|
||||||
func (c *CartMutationContext) RemoveItem(g *CartGrain, m *messages.RemoveItem) error {
|
|
||||||
if m == nil {
|
|
||||||
return fmt.Errorf("RemoveItem: nil payload")
|
|
||||||
}
|
|
||||||
|
|
||||||
targetID := uint32(m.Id)
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if it.Id == targetID {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("RemoveItem: item id %d not found", m.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect the target and, transitively, any children pointing at a removed
|
|
||||||
// line. Loops until no further descendants are found (handles nesting).
|
|
||||||
remove := map[uint32]bool{targetID: true}
|
|
||||||
for {
|
|
||||||
grew := false
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if it.ParentId != nil && remove[*it.ParentId] && !remove[it.Id] {
|
|
||||||
remove[it.Id] = true
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !grew {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kept := g.Items[:0]
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if remove[it.Id] {
|
|
||||||
if it.ReservationEndTime != nil && it.ReservationEndTime.After(time.Now()) {
|
|
||||||
if err := c.ReleaseItem(context.Background(), g.Id, it.Sku, it.StoreId); err != nil {
|
|
||||||
log.Printf("unable to release reservation for item %d: %v", it.Id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, it)
|
|
||||||
}
|
|
||||||
g.Items = kept
|
|
||||||
g.UpdateTotals()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package cart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RemoveLineItemMarking(grain *CartGrain, req *messages.RemoveLineItemMarking) error {
|
|
||||||
|
|
||||||
for i, item := range grain.Items {
|
|
||||||
if item.Id == req.Id {
|
|
||||||
grain.Items[i].Marking = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("item with ID %d not found", req.Id)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *CartMutationContext) SetCartType(g *CartGrain, m *messages.SetCartType) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetCartType: nil payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldType := g.Type
|
||||||
|
newType := m.Type
|
||||||
|
|
||||||
|
if oldType == newType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
|
||||||
|
g.Type = newType
|
||||||
|
|
||||||
|
// If transitioning from regular to wishlist or offer, release any active reservations
|
||||||
|
if oldType == messages.CartType_REGULAR && (newType == messages.CartType_WISHLIST || newType == messages.CartType_OFFER) {
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, item := range g.Items {
|
||||||
|
if item.ReservationEndTime != nil {
|
||||||
|
_ = c.ReleaseItem(ctx, g.Id, item.Sku, item.StoreId)
|
||||||
|
item.ReservationEndTime = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.UpdateTotals()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetRecoveryContact replaces the entire recovery-contact bundle on a cart in
|
||||||
|
// a single event. PUT-style semantics — empty email and an empty token list
|
||||||
|
// both persist as "no contact attached"; callers wanting to clear should send
|
||||||
|
// an empty bundle.
|
||||||
|
//
|
||||||
|
// Trivial whitespace-only normalization is intentionally NOT done here: the
|
||||||
|
// notifier layer owns validation (and the LoggingNotifier just logs). A future
|
||||||
|
// real notifier does its own normalization before sending, so we don't want
|
||||||
|
// to lose the original input.
|
||||||
|
func SetRecoveryContact(grain *CartGrain, req *messages.SetRecoveryContact) error {
|
||||||
|
if req == nil {
|
||||||
|
return errors.New("SetRecoveryContact: nil payload")
|
||||||
|
}
|
||||||
|
grain.Email = req.Email
|
||||||
|
if len(req.PushTokens) == 0 {
|
||||||
|
grain.PushTokens = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tokens := make([]PushToken, 0, len(req.PushTokens))
|
||||||
|
dropped := 0
|
||||||
|
for _, t := range req.PushTokens {
|
||||||
|
if t == nil {
|
||||||
|
// Defensive: a malformed wire request left a nil slot in the
|
||||||
|
// repeated field. Skip rather than crash and surface at debug
|
||||||
|
// level so a misbehaving client is visible without spamming.
|
||||||
|
dropped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tokens = append(tokens, PushToken{
|
||||||
|
Platform: t.Platform,
|
||||||
|
Token: t.Token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if dropped > 0 {
|
||||||
|
log.Printf("SetRecoveryContact: dropped %d nil push-token entries (malformed wire)", dropped)
|
||||||
|
}
|
||||||
|
if len(tokens) == 0 {
|
||||||
|
grain.PushTokens = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
grain.PushTokens = tokens
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package cart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetRecoveryContact_ReplacesEntireBundle verifies PUT semantics: a
|
||||||
|
// second call fully overwrites the first; calling with empty clears it.
|
||||||
|
func TestSetRecoveryContact_ReplacesEntireBundle(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Initial set.
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||||
|
Email: "first@example.com",
|
||||||
|
PushTokens: []*messages.PushToken{
|
||||||
|
{Platform: "fcm", Token: "AAA"},
|
||||||
|
{Platform: "apns", Token: "BBB"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("first set: %v", err)
|
||||||
|
}
|
||||||
|
if g.Email != "first@example.com" {
|
||||||
|
t.Errorf("email = %q, want first@example.com", g.Email)
|
||||||
|
}
|
||||||
|
if len(g.PushTokens) != 2 {
|
||||||
|
t.Fatalf("push tokens = %d, want 2", len(g.PushTokens))
|
||||||
|
}
|
||||||
|
if g.PushTokens[0].Platform != "fcm" || g.PushTokens[1].Platform != "apns" {
|
||||||
|
t.Errorf("platforms = %+v, want [fcm apns]", g.PushTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace with one push token.
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||||
|
Email: "second@example.com",
|
||||||
|
PushTokens: []*messages.PushToken{{Platform: "webpush", Token: "CCC"}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("replace: %v", err)
|
||||||
|
}
|
||||||
|
if g.Email != "second@example.com" {
|
||||||
|
t.Errorf("after replace email = %q, want second@example.com", g.Email)
|
||||||
|
}
|
||||||
|
if len(g.PushTokens) != 1 {
|
||||||
|
t.Fatalf("after replace push tokens = %d, want 1", len(g.PushTokens))
|
||||||
|
}
|
||||||
|
if g.PushTokens[0].Platform != "webpush" {
|
||||||
|
t.Errorf("after replace platform = %q, want webpush", g.PushTokens[0].Platform)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear.
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{}); err != nil {
|
||||||
|
t.Fatalf("clear: %v", err)
|
||||||
|
}
|
||||||
|
if g.Email != "" || len(g.PushTokens) != 0 {
|
||||||
|
t.Errorf("after clear: email=%q tokens=%d, want empty", g.Email, len(g.PushTokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetRecoveryContact_NilPayload verifies defensive nil-input behavior.
|
||||||
|
// We document that nil returns an error (no silent swallow of bad requests).
|
||||||
|
func TestSetRecoveryContact_NilPayload(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(1, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
res, err := reg.Apply(ctx, g, (*messages.SetRecoveryContact)(nil))
|
||||||
|
// registry.Apply treats typed-nil proto messages as a no-op (see
|
||||||
|
// mutation_registry.go's "Typed nil" handling). The behavior we want is
|
||||||
|
// "no mutation applied to grain state" — either via no-op via the
|
||||||
|
// typed-nil path or via an error in the result list. We assert that the
|
||||||
|
// grain state was not modified.
|
||||||
|
if g.Email != "" || len(g.PushTokens) != 0 {
|
||||||
|
t.Errorf("nil-set should not modify grain: email=%q tokens=%d", g.Email, len(g.PushTokens))
|
||||||
|
}
|
||||||
|
_ = res
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetRecoveryContact_SkipsNilTokens verifies nil sub-message tokens are
|
||||||
|
// dropped rather than crashing.
|
||||||
|
func TestSetRecoveryContact_SkipsNilTokens(t *testing.T) {
|
||||||
|
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||||
|
g := NewCartGrain(2, time.Now())
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||||
|
Email: "ok@example.com",
|
||||||
|
PushTokens: []*messages.PushToken{
|
||||||
|
nil,
|
||||||
|
{Platform: "fcm", Token: "REAL"},
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set with nil tokens: %v", err)
|
||||||
|
}
|
||||||
|
if len(g.PushTokens) != 1 {
|
||||||
|
t.Fatalf("push tokens = %d, want 1 (nil entries dropped)", len(g.PushTokens))
|
||||||
|
}
|
||||||
|
if g.PushTokens[0].Platform != "fcm" {
|
||||||
|
t.Errorf("kept platform = %q, want fcm", g.PushTokens[0].Platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoggingNotifier is the v0 default: it writes one INFO line per candidate
|
||||||
|
// and returns nil. It deliberately does NOT pretend to send anything.
|
||||||
|
//
|
||||||
|
// Future implementations (MailerSend, FCM, APNs) implement the same Notifier
|
||||||
|
// interface; the scanner doesn't change. LoggingNotifier also serves as the
|
||||||
|
// "dry-run" mode in production by leaving the seam unconfigured.
|
||||||
|
type LoggingNotifier struct{}
|
||||||
|
|
||||||
|
// NewLoggingNotifier returns a Notifier that logs every candidate. The struct
|
||||||
|
// is empty today; the constructor is kept for parity with future notifiers
|
||||||
|
// that need credentials / config.
|
||||||
|
func NewLoggingNotifier() *LoggingNotifier { return &LoggingNotifier{} }
|
||||||
|
|
||||||
|
// NotifyAbandoned logs the cart id, contact present, and a tiny summary.
|
||||||
|
// Push tokens are summarized by platform only — never log the raw token, so
|
||||||
|
// a misconfigured log aggregator can't leak device handles.
|
||||||
|
func (LoggingNotifier) NotifyAbandoned(_ context.Context, c RecoveryCandidate) error {
|
||||||
|
hasEmail := c.Email != ""
|
||||||
|
hasPush := len(c.PushTokens) > 0
|
||||||
|
channel := "none"
|
||||||
|
switch {
|
||||||
|
case hasEmail && hasPush:
|
||||||
|
channel = "email+push"
|
||||||
|
case hasEmail:
|
||||||
|
channel = "email"
|
||||||
|
case hasPush:
|
||||||
|
channel = "push"
|
||||||
|
}
|
||||||
|
log.Printf(
|
||||||
|
"recovery [dry-run]: cart=%d user=%q channel=%s items=%d total=%d currency=%s email=%q pushPlatforms=%v lastChangeUnix=%d",
|
||||||
|
c.CartID,
|
||||||
|
c.UserID,
|
||||||
|
channel,
|
||||||
|
c.ItemCount,
|
||||||
|
c.TotalIncVat,
|
||||||
|
c.Currency,
|
||||||
|
c.Email,
|
||||||
|
platformsOf(c.PushTokens),
|
||||||
|
c.LastChangeUnix,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func platformsOf(tokens []cart.PushToken) []string {
|
||||||
|
out := make([]string, 0, len(tokens))
|
||||||
|
for _, t := range tokens {
|
||||||
|
out = append(out, t.Platform)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureLog writes log output to an in-memory buffer for inspection.
|
||||||
|
func captureLog(t *testing.T) *bytes.Buffer {
|
||||||
|
t.Helper()
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
orig := log.Writer()
|
||||||
|
flags := log.Flags()
|
||||||
|
prefix := log.Prefix()
|
||||||
|
log.SetOutput(buf)
|
||||||
|
log.SetFlags(0)
|
||||||
|
log.SetPrefix("")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(orig)
|
||||||
|
log.SetFlags(flags)
|
||||||
|
log.SetPrefix(prefix)
|
||||||
|
})
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggingNotifier_LogsPlatformsNotTokens(t *testing.T) {
|
||||||
|
buf := captureLog(t)
|
||||||
|
n := LoggingNotifier{}
|
||||||
|
err := n.NotifyAbandoned(context.Background(), RecoveryCandidate{
|
||||||
|
CartID: 1,
|
||||||
|
Email: "doc@example.com",
|
||||||
|
PushTokens: []cart.PushToken{{Platform: "fcm", Token: "SECRET-TOKEN-MUST-NOT-LEAK"}, {Platform: "apns", Token: "ALSO-SECRET-2"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NotifyAbandoned: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "doc@example.com") {
|
||||||
|
t.Errorf("expected email in log: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "[fcm apns]") {
|
||||||
|
t.Errorf("expected bracketed platform list in log: %s", out)
|
||||||
|
}
|
||||||
|
for _, leak := range []string{"SECRET-TOKEN-MUST-NOT-LEAK", "ALSO-SECRET-2"} {
|
||||||
|
if strings.Contains(out, leak) {
|
||||||
|
t.Errorf("token leaked into log: %s line=%s", leak, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggingNotifier_ClassifiesChannel(t *testing.T) {
|
||||||
|
buf := captureLog(t)
|
||||||
|
n := LoggingNotifier{}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cand RecoveryCandidate
|
||||||
|
wantSub string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "email+push",
|
||||||
|
cand: RecoveryCandidate{Email: "a@b.c", PushTokens: []cart.PushToken{{Platform: "fcm"}}},
|
||||||
|
wantSub: "channel=email+push",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "email only",
|
||||||
|
cand: RecoveryCandidate{Email: "a@b.c"},
|
||||||
|
wantSub: "channel=email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "push only",
|
||||||
|
cand: RecoveryCandidate{PushTokens: []cart.PushToken{{Platform: "fcm"}}},
|
||||||
|
wantSub: "channel=push",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no contact",
|
||||||
|
cand: RecoveryCandidate{},
|
||||||
|
wantSub: "channel=none",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
buf.Reset()
|
||||||
|
if err := n.NotifyAbandoned(context.Background(), tc.cand); err != nil {
|
||||||
|
t.Fatalf("%s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), tc.wantSub) {
|
||||||
|
t.Errorf("%s: log missing %q; got: %s", tc.name, tc.wantSub, buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Package recovery is the v0 abandoned-cart-recovery seam.
|
||||||
|
//
|
||||||
|
// The contract is intentionally narrow: a Scanner finds candidates whose last
|
||||||
|
// mutation is within an "abandoned" window and that have BOTH an attachable
|
||||||
|
// contact (email and/or push tokens) and items in the cart, then hands them
|
||||||
|
// to a Notifier. The Notifier decides what to do — for now a logging shim.
|
||||||
|
//
|
||||||
|
// The seam intentionally avoids:
|
||||||
|
//
|
||||||
|
// - Email/push-token delivery (lives in the notifier impl).
|
||||||
|
// - Idempotency tracking ("did we already notify?"). v0: scanner windows
|
||||||
|
// are sized so a cart falls in once per detection threshold. Real
|
||||||
|
// idempotency is the next pass — see suggest_followups / plan-commerce-
|
||||||
|
// maturity C3.
|
||||||
|
// - Cross-pod coordination. Each cart pod owns its own shard of event logs
|
||||||
|
// (replicated read cache, no leader), so per-pod scanning is correct.
|
||||||
|
// A future AMQP fan-out of `cart.recovery.candidate` would let a single
|
||||||
|
// dedicated notifier consume instead — out of scope here.
|
||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecoveryCandidate is the projectable "we'd remind this shopper" shape.
|
||||||
|
// Fields surface just enough for a future email template / push payload; the
|
||||||
|
// underlying grain carries everything else.
|
||||||
|
type RecoveryCandidate struct {
|
||||||
|
CartID cart.CartId
|
||||||
|
UserID string // empty if the cart hasn't been linked to a profile
|
||||||
|
Email string
|
||||||
|
PushTokens []cart.PushToken
|
||||||
|
ItemCount int
|
||||||
|
TotalIncVat int64 // minor units (money.Cents == int64)
|
||||||
|
Currency string
|
||||||
|
// LastChange is best-effort: the scanner reads the event-log file's
|
||||||
|
// modification time. A pending notifier shouldn't use it as the canonical
|
||||||
|
// "when was this cart touched?" wall clock — see cart.LastChange.
|
||||||
|
LastChangeUnix int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifier is the seam a real email/push implementation plugs into. The
|
||||||
|
// LoggingNotifier is the v0 default. The cart service ships the seam even
|
||||||
|
// before a real send exists so call-sites and tracking can be wired once.
|
||||||
|
type Notifier interface {
|
||||||
|
NotifyAbandoned(ctx context.Context, c RecoveryCandidate) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFixtureLog writes a synthetic `<id>.events.log` to dir with the given
|
||||||
|
// mutations, then pins the file's mtime AFTER Close so the canonical
|
||||||
|
// DiskStorage.SaveLoop flush can't overwrite it on test machines that run
|
||||||
|
// the inner save() during teardown.
|
||||||
|
func writeFixtureLog(t *testing.T, dir string, id uint64, mtime time.Time, muts []proto.Message) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, strconv.FormatUint(id, 10)+".events.log")
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
if err := storage.AppendMutations(id, muts...); err != nil {
|
||||||
|
t.Fatalf("AppendMutations for fixture %d: %v", id, err)
|
||||||
|
}
|
||||||
|
// DiskStorage.Close runs save() which opens+appends the file; that
|
||||||
|
// updates mtime. Pin AFTER Close so the test mtime wins.
|
||||||
|
storage.Close()
|
||||||
|
if err := os.Chtimes(path, mtime, mtime); err != nil {
|
||||||
|
t.Fatalf("Chtimes %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// silenceLogs redirects the standard logger while the scanner runs so test
|
||||||
|
// output stays readable.
|
||||||
|
func silenceLogs(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
orig := log.Writer()
|
||||||
|
prevFlags := log.Flags()
|
||||||
|
log.SetOutput(devNull{})
|
||||||
|
log.SetFlags(0)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
log.SetOutput(orig)
|
||||||
|
log.SetFlags(prevFlags)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type devNull struct{}
|
||||||
|
|
||||||
|
func (devNull) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
|
||||||
|
func TestScanner_FindsCandidateInWindow(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
// Cart that became eligible exactly in the middle of the window:
|
||||||
|
// delay 1h, window 1h → eligible mtime ∈ [now-2h, now-1h].
|
||||||
|
mtime := now.Add(-90 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 42, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000, Sku: "A"},
|
||||||
|
&messages.SetRecoveryContact{Email: "abandoned@example.com"},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1; full result=%+v", len(got), got)
|
||||||
|
}
|
||||||
|
c := got[0]
|
||||||
|
if c.CartID != cart.CartId(42) {
|
||||||
|
t.Errorf("CartID = %d, want 42", c.CartID)
|
||||||
|
}
|
||||||
|
if c.Email != "abandoned@example.com" {
|
||||||
|
t.Errorf("Email = %q, want abandoned@example.com", c.Email)
|
||||||
|
}
|
||||||
|
if c.ItemCount != 1 {
|
||||||
|
t.Errorf("ItemCount = %d, want 1", c.ItemCount)
|
||||||
|
}
|
||||||
|
if c.TotalIncVat != 1000 {
|
||||||
|
t.Errorf("TotalIncVat = %d, want 1000 (AddItem price 1000)", c.TotalIncVat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanner_SkipsCartsOutsideWindow(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
// Too fresh (mtime = now-30m, inside the 1h "still active" cutoff).
|
||||||
|
fresh := now.Add(-30 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 7, fresh, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{Email: "fresh@example.com"},
|
||||||
|
})
|
||||||
|
// Too old (mtime = now-25h, past the 2h window upper bound).
|
||||||
|
old := now.Add(-25 * time.Hour)
|
||||||
|
writeFixtureLog(t, dir, 8, old, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{Email: "old@example.com"},
|
||||||
|
})
|
||||||
|
// Inside window with NO contact (eligibility gate).
|
||||||
|
mid := now.Add(-90 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 9, mid, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
// no SetRecoveryContact
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("candidates = %d, want 0 (all filtered out); result=%+v", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanner_FindsPushOnlyCandidate(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
mtime := now.Add(-75 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 11, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{
|
||||||
|
PushTokens: []*messages.PushToken{
|
||||||
|
{Platform: "fcm", Token: "tok-1"},
|
||||||
|
{Platform: "apns", Token: "tok-2"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Email != "" {
|
||||||
|
t.Errorf("Email = %q, want empty (push-only)", got[0].Email)
|
||||||
|
}
|
||||||
|
if len(got[0].PushTokens) != 2 {
|
||||||
|
t.Fatalf("PushTokens = %d, want 2", len(got[0].PushTokens))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanner_IgnoresWishlistCarts(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
mtime := now.Add(-90 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 13, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000},
|
||||||
|
&messages.SetRecoveryContact{Email: "wishlist@example.com"},
|
||||||
|
&messages.SetCartType{Type: messages.CartType_WISHLIST},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("wishlist candidate leaked: got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanner_TotalPostReplay verifies the bug fix: replay does not run the
|
||||||
|
// post-mutation processor, so the scanner MUST call UpdateTotals to surface
|
||||||
|
// an accurate TotalIncVat.
|
||||||
|
func TestScanner_TotalPostReplay(t *testing.T) {
|
||||||
|
silenceLogs(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
storage := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
||||||
|
|
||||||
|
mtime := now.Add(-75 * time.Minute)
|
||||||
|
writeFixtureLog(t, dir, 21, mtime, []proto.Message{
|
||||||
|
&messages.AddItem{ItemId: 1, Quantity: 2, Price: 500, Sku: "A"},
|
||||||
|
&messages.AddItem{ItemId: 2, Quantity: 1, Price: 1500, Sku: "B"},
|
||||||
|
&messages.SetRecoveryContact{Email: "totals@example.com"},
|
||||||
|
})
|
||||||
|
defer storage.Close()
|
||||||
|
|
||||||
|
scanner := NewScanner(dir, storage)
|
||||||
|
got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Scan: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("candidates = %d, want 1", len(got))
|
||||||
|
}
|
||||||
|
// 2*500 + 1*1500 = 2500 inc-vat
|
||||||
|
if got[0].TotalIncVat != 2500 {
|
||||||
|
t.Errorf("TotalIncVat = %d, want 2500 (1000+1500 across two lines)", got[0].TotalIncVat)
|
||||||
|
}
|
||||||
|
if got[0].ItemCount != 2 {
|
||||||
|
t.Errorf("ItemCount = %d, want 2", got[0].ItemCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsValidEmail sanity for the optional helper exported for notifiers.
|
||||||
|
func TestIsValidEmail(t *testing.T) {
|
||||||
|
if !IsValidEmail("user@example.com") {
|
||||||
|
t.Errorf("user@example.com should be valid")
|
||||||
|
}
|
||||||
|
if IsValidEmail("not-an-email") {
|
||||||
|
t.Errorf("not-an-email should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFileIDFor verifies the helper that parses <id>.events.log filenames.
|
||||||
|
func TestFileIDFor(t *testing.T) {
|
||||||
|
if id, ok := fileIDFor("12345.events.log"); !ok || id != 12345 {
|
||||||
|
t.Errorf("12345.events.log -> %d, %v; want 12345, true", id, ok)
|
||||||
|
}
|
||||||
|
if _, ok := fileIDFor("not-a-number.events.log"); ok {
|
||||||
|
t.Errorf("non-numeric should fail")
|
||||||
|
}
|
||||||
|
if _, ok := fileIDFor("0.events.log"); ok {
|
||||||
|
t.Errorf("id=0 should fail (no implicit zero-cart grain)")
|
||||||
|
}
|
||||||
|
if _, ok := fileIDFor("garbage.txt"); ok {
|
||||||
|
t.Errorf("non-event-log suffix should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package recovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scanner finds RecoveryCandidates by scanning the per-pod event-log directory.
|
||||||
|
//
|
||||||
|
// The model:
|
||||||
|
// - Each cart pod owns a shard of `<id>.events.log` files under `dir`.
|
||||||
|
// - File mtime approximates "last successful mutation" cheaply — replay only
|
||||||
|
// when something lands in the window.
|
||||||
|
// - For each candidate file, replay the grain (a few kb, ms-scale) and build
|
||||||
|
// a RecoveryCandidate iff it has items + a contact.
|
||||||
|
//
|
||||||
|
// Two important implementation details:
|
||||||
|
//
|
||||||
|
// 1. After LoadEvents we MUST call UpdateTotals(). Replaying event handlers
|
||||||
|
// runs the per-mutation registered handlers (SetUserId, SetRecoveryContact,
|
||||||
|
// AddItem, ...) but NOT the post-mutation processors — those run only on
|
||||||
|
// the live mutation path (see cmd/cart/main.go::RegisterProcessor for the
|
||||||
|
// "Totals and promotions" pipeline). Without this call, g.TotalPrice is
|
||||||
|
// nil and TotalIncVat in the candidate is always 0.
|
||||||
|
//
|
||||||
|
// 2. Read-vs-write races: DiskStorage may be in the middle of appending while
|
||||||
|
// Scan reads. The bufio scanner will fail JSON-unmarshal on a torn line and
|
||||||
|
// we silently continue — LogEvents treats that as "skip", here we do too.
|
||||||
|
// A persistent unreadable file is a separate problem runPurge handles.
|
||||||
|
//
|
||||||
|
// v0 deliberately does NOT track "already notified" within this scanner —
|
||||||
|
// the window sizing plus caller conventions give roughly-once delivery.
|
||||||
|
// Idempotency is the next pass.
|
||||||
|
type Scanner struct {
|
||||||
|
dir string
|
||||||
|
storage *actor.DiskStorage[cart.CartGrain]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScanner returns a Scanner over dir (the same CART_DIR used by the cart
|
||||||
|
// service). storage must share the same dir so LoadEvents reading paths
|
||||||
|
// match the scanning paths.
|
||||||
|
func NewScanner(dir string, storage *actor.DiskStorage[cart.CartGrain]) *Scanner {
|
||||||
|
return &Scanner{dir: dir, storage: storage}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan returns candidates whose event-log mtime falls in
|
||||||
|
// [now-delay-window, now-delay] AND whose grain has items AND (email OR
|
||||||
|
// push tokens). The half-open window semantics match the caller's intent:
|
||||||
|
//
|
||||||
|
// "carts whose mtime is in this slice of the recent past"
|
||||||
|
//
|
||||||
|
// — younger than now-delay (still active) and older than now-delay-window
|
||||||
|
// (never recovery-eligible yet).
|
||||||
|
//
|
||||||
|
// Empty slice + nil error means "no candidates this pass"; an error means the
|
||||||
|
// pass itself failed and the caller should log + retry.
|
||||||
|
func (s *Scanner) Scan(ctx context.Context, delay, window time.Duration, now time.Time) ([]RecoveryCandidate, error) {
|
||||||
|
if s == nil || s.storage == nil || s.dir == "" {
|
||||||
|
return nil, fmt.Errorf("recovery: scanner not configured")
|
||||||
|
}
|
||||||
|
if delay <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if window <= 0 {
|
||||||
|
window = delay
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := now.Add(-(delay + window))
|
||||||
|
upper := now.Add(-delay)
|
||||||
|
entries, err := os.ReadDir(s.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("recovery: read dir %s: %w", s.dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]RecoveryCandidate, 0)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasSuffix(name, ".events.log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, ok := fileIDFor(name)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, infoErr := e.Info()
|
||||||
|
if infoErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mtime := info.ModTime()
|
||||||
|
// Closed window:
|
||||||
|
// mtime BEFORE lower → never reached the abandonment threshold yet.
|
||||||
|
// mtime AFTER upper → still too fresh (caller is actively using it).
|
||||||
|
if mtime.Before(lower) || mtime.After(upper) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
grain := cart.NewCartGrain(id, mtime)
|
||||||
|
if loadErr := s.storage.LoadEvents(ctx, id, grain); loadErr != nil {
|
||||||
|
// Skip unreadable grains — they're purged separately by runPurge.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Replay does NOT run the Totals-and-promotions processor (see the
|
||||||
|
// package doc); recompute TotalPrice from the item list so the
|
||||||
|
// candidate TotalIncVat matches what the live API would return.
|
||||||
|
grain.UpdateTotals()
|
||||||
|
if !eligible(grain) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, buildCandidate(id, grain, mtime))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eligible mirrors "would we ever want to send a reminder?" — items present
|
||||||
|
// and a contact available. The grain's own UpdateTotals has already been
|
||||||
|
// run by every mutation's processor, so TotalPrice is reliable here.
|
||||||
|
//
|
||||||
|
// Wishlists / offers are excluded — a wishlist cart isn't forgotten, and an
|
||||||
|
// offer cart has its own delivery semantics.
|
||||||
|
func eligible(g *cart.CartGrain) bool {
|
||||||
|
if g == nil || len(g.Items) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch g.Type {
|
||||||
|
case cart_messages.CartType_WISHLIST, cart_messages.CartType_OFFER:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasContact := strings.TrimSpace(g.Email) != "" || len(g.PushTokens) > 0
|
||||||
|
return hasContact
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCandidate projects the replayed grain into a RecoveryCandidate. Email
|
||||||
|
// validation is intentionally lenient — we hand the value to the notifier
|
||||||
|
// and the notifier decides what to send; rejecting "looks-invalid" addresses
|
||||||
|
// here would just hide useful debugging signal from the dry-run logs.
|
||||||
|
func buildCandidate(id uint64, g *cart.CartGrain, mtime time.Time) RecoveryCandidate {
|
||||||
|
var total int64
|
||||||
|
if g.TotalPrice != nil {
|
||||||
|
// tax.Price.IncVat is int64-backed via money.Cents. Reading the field
|
||||||
|
// directly keeps the recovery layer decoupled from platform/money
|
||||||
|
// internals.
|
||||||
|
total = int64(g.TotalPrice.IncVat)
|
||||||
|
}
|
||||||
|
tokens := make([]cart.PushToken, len(g.PushTokens))
|
||||||
|
copy(tokens, g.PushTokens)
|
||||||
|
return RecoveryCandidate{
|
||||||
|
CartID: cart.CartId(id),
|
||||||
|
UserID: g.UserId,
|
||||||
|
Email: g.Email,
|
||||||
|
PushTokens: tokens,
|
||||||
|
ItemCount: len(g.Items),
|
||||||
|
TotalIncVat: total,
|
||||||
|
Currency: g.Currency,
|
||||||
|
LastChangeUnix: mtime.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidEmail is exported as a convenience the notifier may use to decide
|
||||||
|
// whether to actually send (MailerSend will reject invalid); the scanner
|
||||||
|
// itself is permissive.
|
||||||
|
func IsValidEmail(s string) bool {
|
||||||
|
_, err := mail.ParseAddress(s)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileIDFor is a small helper that extracts the grain id from an .events.log
|
||||||
|
// filename. Returns 0/false if the name doesn't match the expected shape.
|
||||||
|
func fileIDFor(name string) (uint64, bool) {
|
||||||
|
base := strings.TrimSuffix(name, ".events.log")
|
||||||
|
id, err := strconv.ParseUint(base, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
@@ -242,6 +242,11 @@ func (e *Engine) Validate(def *Definition) error {
|
|||||||
if _, ok := e.reg.hook(h.Type); !ok {
|
if _, ok := e.reg.hook(h.Type); !ok {
|
||||||
return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type)
|
return fmt.Errorf("flow %q step %q: unknown hook %q", def.Name, s.Name, h.Type)
|
||||||
}
|
}
|
||||||
|
if h.When != "" {
|
||||||
|
if _, ok := e.reg.predicate(h.When); !ok {
|
||||||
|
return fmt.Errorf("flow %q step %q: hook %q: unknown predicate %q", def.Name, s.Name, h.Type, h.When)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,8 +342,26 @@ func (e *Engine) compensate(ctx context.Context, res *Result, executed []Step, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fireHooks runs every hook for a phase, logging (never propagating) failures.
|
// fireHooks runs every hook for a phase, logging (never propagating) failures.
|
||||||
|
// Hooks with a When predicate are evaluated first; the hook is skipped when the
|
||||||
|
// predicate returns false.
|
||||||
func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) {
|
func (e *Engine) fireHooks(ctx context.Context, st *State, refs []HookRef, info HookInfo) {
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
|
if ref.When != "" {
|
||||||
|
pred, ok := e.reg.predicate(ref.When)
|
||||||
|
if !ok {
|
||||||
|
st.Logger.Error("flow hook skipped: unknown predicate", "hook", ref.Type, "predicate", ref.When, "step", info.Step)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run, err := pred(ctx, st)
|
||||||
|
if err != nil {
|
||||||
|
st.Logger.Error("flow hook predicate error", "hook", ref.Type, "predicate", ref.When, "step", info.Step, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !run {
|
||||||
|
st.Logger.Debug("flow hook skipped by predicate", "hook", ref.Type, "predicate", ref.When, "step", info.Step)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
h, ok := e.reg.hook(ref.Type)
|
h, ok := e.reg.hook(ref.Type)
|
||||||
if !ok {
|
if !ok {
|
||||||
st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
|
st.Logger.Error("flow hook skipped: unknown type", "hook", ref.Type, "step", info.Step)
|
||||||
|
|||||||
+3
-1
@@ -51,9 +51,11 @@ type Hooks struct {
|
|||||||
OnError []HookRef `json:"onError,omitempty"`
|
OnError []HookRef `json:"onError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HookRef names a registered hook and its params.
|
// HookRef names a registered hook and its params. When, if set, names a
|
||||||
|
// registered predicate; the hook is skipped when it returns false.
|
||||||
type HookRef struct {
|
type HookRef struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
When string `json:"when,omitempty"`
|
||||||
Params json.RawMessage `json:"params,omitempty"`
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -63,7 +63,7 @@ const PlaceOrderVar = "placeOrder"
|
|||||||
// RegisterFlowActions registers the order lifecycle actions on reg, driving the
|
// RegisterFlowActions registers the order lifecycle actions on reg, driving the
|
||||||
// grain through app and taking payment via provider. Compose with
|
// grain through app and taking payment via provider. Compose with
|
||||||
// flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too.
|
// flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too.
|
||||||
func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider) {
|
func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider, prefCheckers ...EmailPreferenceChecker) {
|
||||||
reg.ActionWithMeta("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
reg.ActionWithMeta("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||||
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
|
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
|
||||||
if !ok || po == nil {
|
if !ok || po == nil {
|
||||||
@@ -243,7 +243,11 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
ExampleParams: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"TRACK-123","trackingUri":"https://tracking.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
|
ExampleParams: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"TRACK-123","trackingUri":"https://tracking.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
registerPredicates(reg, app)
|
var checker EmailPreferenceChecker
|
||||||
|
if len(prefCheckers) > 0 {
|
||||||
|
checker = prefCheckers[0]
|
||||||
|
}
|
||||||
|
registerPredicates(reg, app, checker)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerPredicates adds the order gating predicates a step can reference via
|
// registerPredicates adds the order gating predicates a step can reference via
|
||||||
@@ -251,7 +255,7 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
|||||||
// at the moment the step is reached (e.g. only fire a receipt hook once the
|
// at the moment the step is reached (e.g. only fire a receipt hook once the
|
||||||
// order is captured). Predicates take no params (the engine's When is a bare
|
// order is captured). Predicates take no params (the engine's When is a bare
|
||||||
// name), so each is a fixed, composable boolean.
|
// name), so each is a fixed, composable boolean.
|
||||||
func registerPredicates(reg *flow.Registry, app Applier) {
|
func registerPredicates(reg *flow.Registry, app Applier, prefChecker EmailPreferenceChecker) {
|
||||||
get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) }
|
get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) }
|
||||||
|
|
||||||
reg.PredicateWithMeta("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
|
reg.PredicateWithMeta("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
@@ -289,4 +293,30 @@ func registerPredicates(reg *flow.Registry, app Applier) {
|
|||||||
}
|
}
|
||||||
return o.CapturedAmount-o.RefundedAmount > 0, nil
|
return o.CapturedAmount-o.RefundedAmount > 0, nil
|
||||||
}, flow.CapabilityMeta{Description: "Run only when captured amount remains to refund."})
|
}, flow.CapabilityMeta{Description: "Run only when captured amount remains to refund."})
|
||||||
|
|
||||||
|
// Email consent predicates — gate marketing or order steps when the customer
|
||||||
|
// has opted out in their profile email preferences.
|
||||||
|
if prefChecker != nil {
|
||||||
|
reg.PredicateWithMeta("has_marketing_consent", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
|
o, err := get(ctx, st)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if o.CustomerEmail == "" {
|
||||||
|
return false, nil // no email address -> no consent
|
||||||
|
}
|
||||||
|
return prefChecker.Allowed(ctx, o.CustomerEmail, "marketing"), nil
|
||||||
|
}, flow.CapabilityMeta{Description: "Run only when the customer has consented to marketing emails."})
|
||||||
|
|
||||||
|
reg.PredicateWithMeta("has_order_email_consent", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||||
|
o, err := get(ctx, st)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if o.CustomerEmail == "" {
|
||||||
|
return false, nil // no email address -> cannot receive order emails
|
||||||
|
}
|
||||||
|
return prefChecker.Allowed(ctx, o.CustomerEmail, "order"), nil
|
||||||
|
}, flow.CapabilityMeta{Description: "Run only when the customer has consented to order transactional emails."})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ func newPool(t *testing.T) *actor.SimpleGrainPool[OrderGrain] {
|
|||||||
return pool
|
return pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// alwaysAllowChecker is a mock EmailPreferenceChecker that allows every email.
|
||||||
|
type alwaysAllowChecker struct{}
|
||||||
|
|
||||||
|
func (alwaysAllowChecker) Allowed(_ context.Context, _ string, _ string) bool { return true }
|
||||||
|
|
||||||
func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
|
func newFlowRegistry(pool Applier, provider PaymentProvider) *flow.Registry {
|
||||||
reg := flow.NewRegistry()
|
reg := flow.NewRegistry()
|
||||||
flow.RegisterBuiltinHooks(reg)
|
flow.RegisterBuiltinHooks(reg)
|
||||||
RegisterFlowActions(reg, pool, provider)
|
RegisterFlowActions(reg, pool, provider, alwaysAllowChecker{})
|
||||||
// place-and-pay references the emit_order_created hook; register it (nil
|
// place-and-pay references the emit_order_created hook; register it (nil
|
||||||
// publisher = no-op) so flow validation passes in tests too.
|
// publisher = no-op) so flow validation passes in tests too.
|
||||||
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order")
|
RegisterOrderCreatedEmit(reg, pool, nil, "order", "order")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"after": [
|
"after": [
|
||||||
{ "type": "log", "params": { "message": "fulfillment created" } },
|
{ "type": "log", "params": { "message": "fulfillment created" } },
|
||||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "fulfillment-shipped" } },
|
{ "type": "send_email", "when": "has_order_email_consent", "params": { "template": "fulfillment-shipped" } },
|
||||||
{ "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true }
|
{ "type": "fulfillment_webhook", "params": { "url": "", "method": "POST" }, "continueOnError": true }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"after": [
|
"after": [
|
||||||
{ "type": "log", "params": { "message": "order placed" } },
|
{ "type": "log", "params": { "message": "order placed" } },
|
||||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "order-confirmation" } }
|
{ "type": "send_email", "when": "has_order_email_consent", "params": { "template": "order-confirmation" } }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"after": [
|
"after": [
|
||||||
{ "type": "log", "params": { "message": "payment captured" } },
|
{ "type": "log", "params": { "message": "payment captured" } },
|
||||||
{ "type": "send_email", "when": "has_customer_email", "params": { "template": "payment-receipt" } },
|
{ "type": "send_email", "when": "has_order_email_consent", "params": { "template": "payment-receipt" } },
|
||||||
{ "type": "emit_order_created" }
|
{ "type": "emit_order_created" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+122
-11
@@ -285,24 +285,53 @@ func applyTieredDiscount(g *cart.CartGrain, a Action) *cart.Price {
|
|||||||
return applyPercentageDiscount(g, pct)
|
return applyPercentageDiscount(g, pct)
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyPercentageDiscount removes pct% of the current cart total, preserving the
|
// applyPercentageDiscount removes pct% of each line's row total, sums
|
||||||
// VAT-rate breakdown proportionally, recording it in TotalDiscount and returning
|
// the per-line amounts into the cart's TotalDiscount, and returns the
|
||||||
// the discount applied (nil if nothing was taken off).
|
// total (nil if nothing was taken off). Per-line first then sum
|
||||||
|
// (instead of total first then split) avoids penny-level rounding
|
||||||
|
// gaps where the per-line amounts no longer sum to the reported
|
||||||
|
// total. The per-line amount is also added to each line's Discount
|
||||||
|
// field on top of the OrgPrice-based discount that UpdateTotals
|
||||||
|
// already set, so consumers (the /promotions/evaluate-with-cart
|
||||||
|
// preview, the checkout payload mapper) can see the effective unit
|
||||||
|
// price per item.
|
||||||
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
func applyPercentageDiscount(g *cart.CartGrain, pct float64) *cart.Price {
|
||||||
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
if pct <= 0 || g.TotalPrice.IncVat <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
discount := scalePrice(g.TotalPrice, pct)
|
totalDiscount := cart.NewPrice()
|
||||||
if discount.IncVat <= 0 {
|
for _, item := range g.Items {
|
||||||
|
if item == nil || item.TotalPrice.IncVat <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
itemDisc := scalePrice(&item.TotalPrice, pct)
|
||||||
|
if itemDisc.IncVat <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Clamp to this line's row total so a single item can't go
|
||||||
|
// negative when its per-line discount (post-rounding) ends
|
||||||
|
// up larger than its row. itemDisc is *cart.Price; assign
|
||||||
|
// through the pointer to overwrite the rounded value with
|
||||||
|
// the raw row total when needed.
|
||||||
|
if itemDisc.IncVat > item.TotalPrice.IncVat {
|
||||||
|
*itemDisc = item.TotalPrice
|
||||||
|
}
|
||||||
|
if item.Discount == nil {
|
||||||
|
item.Discount = cart.NewPrice()
|
||||||
|
}
|
||||||
|
item.Discount.Add(*itemDisc)
|
||||||
|
totalDiscount.Add(*itemDisc)
|
||||||
|
}
|
||||||
|
if totalDiscount.IncVat <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Never discount below zero.
|
// Safety net for any aggregate rounding overshoot.
|
||||||
if discount.IncVat > g.TotalPrice.IncVat {
|
if totalDiscount.IncVat > g.TotalPrice.IncVat {
|
||||||
discount = g.TotalPrice
|
totalDiscount = g.TotalPrice
|
||||||
}
|
}
|
||||||
g.TotalDiscount.Add(*discount)
|
g.TotalDiscount.Add(*totalDiscount)
|
||||||
g.TotalPrice.Subtract(*discount)
|
g.TotalPrice.Subtract(*totalDiscount)
|
||||||
return discount
|
return totalDiscount
|
||||||
}
|
}
|
||||||
|
|
||||||
// selectTier returns the discount percent for the band the total falls into.
|
// selectTier returns the discount percent for the band the total falls into.
|
||||||
@@ -437,6 +466,17 @@ func (buyXGetYEffect) Apply(g *cart.CartGrain, rule PromotionRule, a Action) (*c
|
|||||||
unitDiscount := scalePrice(&units[i].item.Price, discount)
|
unitDiscount := scalePrice(&units[i].item.Price, discount)
|
||||||
totalDiscount.Add(*unitDiscount)
|
totalDiscount.Add(*unitDiscount)
|
||||||
affectedMap[units[i].item.Id] = true
|
affectedMap[units[i].item.Id] = true
|
||||||
|
// Per-line distribution: add the per-unit discount to the
|
||||||
|
// item's Discount field on top of the OrgPrice-based
|
||||||
|
// discount that UpdateTotals already set. If the same item
|
||||||
|
// is the free unit multiple times (e.g. qty 3 with
|
||||||
|
// buy-2-get-1 and a second buy-2-get-1), Add is called
|
||||||
|
// per free unit so the cumulative amount lands on the
|
||||||
|
// right line.
|
||||||
|
if units[i].item.Discount == nil {
|
||||||
|
units[i].item.Discount = cart.NewPrice()
|
||||||
|
}
|
||||||
|
units[i].item.Discount.Add(*unitDiscount)
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalDiscount.IncVat <= 0 {
|
if totalDiscount.IncVat <= 0 {
|
||||||
@@ -590,6 +630,15 @@ func (bundleDiscountEffect) Apply(g *cart.CartGrain, _ PromotionRule, a Action)
|
|||||||
|
|
||||||
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
if bundleDiscount != nil && bundleDiscount.IncVat > 0 {
|
||||||
totalDiscount.Add(*bundleDiscount)
|
totalDiscount.Add(*bundleDiscount)
|
||||||
|
// Per-line distribution: split the bundle discount
|
||||||
|
// across the entries in `bundle` proportionally to
|
||||||
|
// each entry's per-unit price (same item can appear
|
||||||
|
// multiple times if it's in the bundle multiple
|
||||||
|
// times). distributeBundleDiscount walks the entries
|
||||||
|
// and adds the share to each item's Discount field,
|
||||||
|
// with the last entry absorbing any rounding remainder
|
||||||
|
// so the per-line sum matches bundleDiscount exactly.
|
||||||
|
distributeBundleDiscount(bundle, bundleDiscount)
|
||||||
for _, item := range bundle {
|
for _, item := range bundle {
|
||||||
affectedMap[item.Id] = true
|
affectedMap[item.Id] = true
|
||||||
}
|
}
|
||||||
@@ -623,6 +672,68 @@ func (bundleDiscountEffect) Progress(s *PromotionService, rule PromotionRule, a
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
|
|
||||||
|
// distributeBundleDiscount splits bundleDiscount across the entries
|
||||||
|
// in bundleItems proportionally to each entry's per-unit price, and
|
||||||
|
// adds the share to each item's Discount field. The same item may
|
||||||
|
// appear multiple times in bundleItems (when it's in the bundle
|
||||||
|
// multiple times); each entry gets its own share, and Add on the
|
||||||
|
// item's Discount field is cumulative. The last entry absorbs any
|
||||||
|
// rounding remainder on IncVat (and on each VAT rate) so the
|
||||||
|
// per-line sum matches bundleDiscount exactly. No-op when
|
||||||
|
// bundleDiscount is nil/zero or bundleItems is empty.
|
||||||
|
func distributeBundleDiscount(bundleItems []*cart.CartItem, bundleDiscount *cart.Price) {
|
||||||
|
if bundleDiscount == nil || bundleDiscount.IncVat <= 0 || len(bundleItems) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total := int64(0)
|
||||||
|
prices := make([]int64, len(bundleItems))
|
||||||
|
for i, item := range bundleItems {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prices[i] = item.Price.IncVat.Int64()
|
||||||
|
total += prices[i]
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var allocated int64
|
||||||
|
for i, item := range bundleItems {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var shareIncVat int64
|
||||||
|
if i == len(bundleItems)-1 {
|
||||||
|
shareIncVat = bundleDiscount.IncVat.Int64() - allocated
|
||||||
|
} else {
|
||||||
|
shareIncVat = bundleDiscount.IncVat.Int64() * prices[i] / total
|
||||||
|
}
|
||||||
|
if shareIncVat <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sharePrice := &cart.Price{
|
||||||
|
IncVat: money.Cents(shareIncVat),
|
||||||
|
VatRates: make(map[int]money.Cents, len(bundleDiscount.VatRates)),
|
||||||
|
}
|
||||||
|
// Pro-rate each VAT rate by the same share fraction so the
|
||||||
|
// VAT breakdown on the per-line discount matches the
|
||||||
|
// proportional split. Guard against the (degenerate) case
|
||||||
|
// where bundleDiscount.IncVat is somehow 0 here — the
|
||||||
|
// outer guard at the top should prevent this, but be
|
||||||
|
// safe.
|
||||||
|
if bundleDiscount.IncVat.Int64() > 0 {
|
||||||
|
for rate, amount := range bundleDiscount.VatRates {
|
||||||
|
sharePrice.VatRates[rate] = money.Cents(amount.Int64() * shareIncVat / bundleDiscount.IncVat.Int64())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.Discount == nil {
|
||||||
|
item.Discount = cart.NewPrice()
|
||||||
|
}
|
||||||
|
item.Discount.Add(*sharePrice)
|
||||||
|
allocated += shareIncVat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
|
func eligibleItems(rule PromotionRule, g *cart.CartGrain) []*cart.CartItem {
|
||||||
var categories []string
|
var categories []string
|
||||||
var productIDs []string
|
var productIDs []string
|
||||||
|
|||||||
+165
-27
@@ -2,6 +2,7 @@ package promotions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
@@ -16,6 +17,11 @@ import (
|
|||||||
// creating or mutating a cart. It builds a synthetic CartGrain from the request,
|
// creating or mutating a cart. It builds a synthetic CartGrain from the request,
|
||||||
// runs the same EvaluateAll + ApplyResults path the live cart uses, and returns
|
// runs the same EvaluateAll + ApplyResults path the live cart uses, and returns
|
||||||
// the resulting totals plus the applied/pending effect breakdown.
|
// the resulting totals plus the applied/pending effect breakdown.
|
||||||
|
//
|
||||||
|
// The per-line breakdown (cart.EvaluatedItem) is computed via the canonical
|
||||||
|
// cart.MapEvaluatedItems so the preview and post-add responses share shape
|
||||||
|
// and rounding rules — see pkg/cart/evaluated_item.go for the type and the
|
||||||
|
// map function.
|
||||||
|
|
||||||
// EvalItem is one — possibly partial — cart line for a stateless evaluation.
|
// EvalItem is one — possibly partial — cart line for a stateless evaluation.
|
||||||
// Missing fields fall back to sensible defaults (qty 1, 25% VAT) so the engine
|
// Missing fields fall back to sensible defaults (qty 1, 25% VAT) so the engine
|
||||||
@@ -42,29 +48,138 @@ type EvaluateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
|
// EvaluateResponse is the outcome of a stateless evaluation: the totals after
|
||||||
// promotions and the per-promotion breakdown (applied discounts plus pending
|
// promotions, the per-promotion breakdown (applied discounts plus pending
|
||||||
// "spend X more for ..." nudges).
|
// "spend X more for ..." nudges), and the per-line breakdown so the
|
||||||
|
// storefront can render the real (post-discount) unit price for each item.
|
||||||
|
//
|
||||||
|
// Items is the same []cart.EvaluatedItem shape the live cart exposes on
|
||||||
|
// CartGrain.EvaluatedItems — referenced from cart so the preview and the
|
||||||
|
// live response match byte-for-byte on the wire.
|
||||||
type EvaluateResponse struct {
|
type EvaluateResponse struct {
|
||||||
TotalPrice *cart.Price `json:"totalPrice"`
|
TotalPrice *cart.Price `json:"totalPrice"`
|
||||||
TotalDiscount *cart.Price `json:"totalDiscount"`
|
TotalDiscount *cart.Price `json:"totalDiscount"`
|
||||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||||
|
Items []cart.EvaluatedItem `json:"items,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate runs rules against a synthetic cart built from req and returns the
|
// Evaluate runs rules against a synthetic cart built from req and returns the
|
||||||
// resulting totals and effects without touching any real cart state.
|
// resulting totals and effects without touching any real cart state.
|
||||||
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
func (s *PromotionService) Evaluate(rules []PromotionRule, req EvaluateRequest) EvaluateResponse {
|
||||||
g := req.toCart(s.DefaultTaxProvider)
|
g := req.toCart(s.DefaultTaxProvider)
|
||||||
g.UpdateTotals()
|
s.EvaluateAndApply(rules, g, req.ContextOptions()...)
|
||||||
ctx := NewContextFromCart(g, req.contextOptions()...)
|
|
||||||
results, _ := s.EvaluateAll(rules, ctx)
|
|
||||||
s.ApplyResults(g, results, ctx)
|
|
||||||
return EvaluateResponse{
|
return EvaluateResponse{
|
||||||
TotalPrice: g.TotalPrice,
|
TotalPrice: g.TotalPrice,
|
||||||
TotalDiscount: g.TotalDiscount,
|
TotalDiscount: g.TotalDiscount,
|
||||||
AppliedPromotions: g.AppliedPromotions,
|
AppliedPromotions: g.AppliedPromotions,
|
||||||
|
Items: cart.MapEvaluatedItems(g),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EvaluateAndApply runs the canonical promotion-evaluation pipeline on
|
||||||
|
// an existing grain. It is the single entry point every caller that
|
||||||
|
// needs "what would the engine do for this cart" MUST go through —
|
||||||
|
// the live cart processor in cmd/cart/main.go's reg.RegisterProcessor
|
||||||
|
// and the /promotions/evaluate-with-cart preview handler in
|
||||||
|
// cmd/cart/promotions_evaluate.go both call this method, so the
|
||||||
|
// preview and post-add behavior stay in lockstep (especially in the
|
||||||
|
// coupon+promotion-overlap edge case where a promotion rule with a
|
||||||
|
// coupon_code condition would otherwise stomp a voucher the customer
|
||||||
|
// has applied). If a new caller needs the same pipeline, add it here
|
||||||
|
// — do not duplicate the steps.
|
||||||
|
//
|
||||||
|
// The pipeline is:
|
||||||
|
//
|
||||||
|
// 1. Clear Vouchers.BypassedByPromotions on every voucher so the
|
||||||
|
// re-eval trigger (step 5) works correctly on each invocation —
|
||||||
|
// otherwise a coupon that was already bypassed on a previous
|
||||||
|
// pass would not be re-marked and step 5 would not fire.
|
||||||
|
// 2. UpdateTotals on the grain (recomputes TotalPrice, TotalDiscount
|
||||||
|
// from the current items + vouchers).
|
||||||
|
// 3. Build a context from the grain (via NewContextFromCart with the
|
||||||
|
// supplied opts; defaults to WithNow(time.Now()) only when no
|
||||||
|
// opts are passed at all — callers that pass non-empty opts
|
||||||
|
// without WithNow get the engine's built-in default).
|
||||||
|
// 4. EvaluateAll against the rules.
|
||||||
|
// 5. markCouponsBypassed scans the results for applicable rules
|
||||||
|
// with a coupon_code condition matching a voucher in the grain;
|
||||||
|
// matching vouchers get BypassedByPromotions set. If any voucher
|
||||||
|
// was newly marked, re-run steps 2-4 (the live cart has done this
|
||||||
|
// since the bypass pass; the preview must match exactly).
|
||||||
|
// 6. ApplyResults records every effect (applied discounts + pending
|
||||||
|
// "spend X more for ..." nudges) on the grain.
|
||||||
|
//
|
||||||
|
// The grain is mutated in place; for a read-only what-if, deep-copy
|
||||||
|
// the grain first (see cmd/cart/promotions_evaluate.go's
|
||||||
|
// cloneCartForPreview).
|
||||||
|
func (s *PromotionService) EvaluateAndApply(rules []PromotionRule, g *cart.CartGrain, opts ...ContextOption) {
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil {
|
||||||
|
v.BypassedByPromotions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(opts) == 0 {
|
||||||
|
opts = []ContextOption{WithNow(time.Now())}
|
||||||
|
}
|
||||||
|
g.UpdateTotals()
|
||||||
|
ctx := NewContextFromCart(g, opts...)
|
||||||
|
results, _ := s.EvaluateAll(rules, ctx)
|
||||||
|
if markCouponsBypassed(g, results) {
|
||||||
|
g.UpdateTotals()
|
||||||
|
ctx = NewContextFromCart(g, opts...)
|
||||||
|
results, _ = s.EvaluateAll(rules, ctx)
|
||||||
|
}
|
||||||
|
s.ApplyResults(g, results, ctx)
|
||||||
|
// Populate the per-line breakdown now that ApplyResults is done —
|
||||||
|
// every effect has distributed per-line Discounts onto the grain's
|
||||||
|
// items, so MapEvaluatedItems reads consistent state. Sits on the
|
||||||
|
// canonical pipeline so callers (live-cart processor, the cart-aware
|
||||||
|
// preview handler, internal/admin mutations) never have to remember
|
||||||
|
// to refresh it themselves; downstream readers (UCP cart response,
|
||||||
|
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) see a
|
||||||
|
// grain that already carries the per-line breakdown on its own
|
||||||
|
// EvaluatedItems field.
|
||||||
|
g.EvaluatedItems = cart.MapEvaluatedItems(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
// markCouponsBypassed scans the evaluation results for any applicable
|
||||||
|
// rule with a coupon_code condition whose value matches a voucher in
|
||||||
|
// the grain; marks matching vouchers' BypassedByPromotions flag and
|
||||||
|
// returns true if any were newly marked. Called by EvaluateAndApply
|
||||||
|
// between the first and second EvaluateAll pass — see that method's
|
||||||
|
// doc for why the re-eval is needed.
|
||||||
|
func markCouponsBypassed(g *cart.CartGrain, results []EvaluationResult) bool {
|
||||||
|
hasBypassed := false
|
||||||
|
for _, res := range results {
|
||||||
|
if !res.Applicable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
WalkConditions(res.Rule.Conditions, func(c Condition) bool {
|
||||||
|
bc, ok := c.(BaseCondition)
|
||||||
|
if !ok || bc.Type != CondCouponCode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var codes []string
|
||||||
|
if s, ok := bc.Value.AsString(); ok {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
||||||
|
for _, s := range arr {
|
||||||
|
codes = append(codes, strings.ToLower(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, code := range codes {
|
||||||
|
for _, v := range g.Vouchers {
|
||||||
|
if v != nil && strings.ToLower(v.Code) == code && !v.BypassedByPromotions {
|
||||||
|
v.BypassedByPromotions = true
|
||||||
|
hasBypassed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return hasBypassed
|
||||||
|
}
|
||||||
|
|
||||||
// toCart materialises the request into a throwaway CartGrain.
|
// toCart materialises the request into a throwaway CartGrain.
|
||||||
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
// tp is an optional TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
||||||
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
||||||
@@ -74,25 +189,7 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
|||||||
}
|
}
|
||||||
g := cart.NewCartGrain(0, now)
|
g := cart.NewCartGrain(0, now)
|
||||||
for _, it := range req.Items {
|
for _, it := range req.Items {
|
||||||
qty := it.Quantity
|
g.Items = append(g.Items, it.ToCartItem(tp))
|
||||||
if qty == 0 {
|
|
||||||
qty = 1
|
|
||||||
}
|
|
||||||
// it.VatRate is the request rate in raw percent (external input); convert
|
|
||||||
// to the platform basis-point scale at this boundary.
|
|
||||||
vat := int(math.Round(float64(it.VatRate) * 100))
|
|
||||||
if vat == 0 {
|
|
||||||
vat = defaultVatRate(tp)
|
|
||||||
}
|
|
||||||
item := &cart.CartItem{
|
|
||||||
Sku: it.Sku,
|
|
||||||
Quantity: qty,
|
|
||||||
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
|
|
||||||
}
|
|
||||||
if it.Category != "" {
|
|
||||||
item.Meta = &cart.ItemMeta{Category: it.Category}
|
|
||||||
}
|
|
||||||
g.Items = append(g.Items, item)
|
|
||||||
}
|
}
|
||||||
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
if len(g.Items) == 0 && req.CartTotalIncVat > 0 {
|
||||||
vat := defaultVatRate(tp)
|
vat := defaultVatRate(tp)
|
||||||
@@ -105,6 +202,41 @@ func (req EvaluateRequest) toCart(tp cart.TaxProvider) *cart.CartGrain {
|
|||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToCartItem converts this EvalItem into a CartItem. tp is an optional
|
||||||
|
// TaxProvider for the default VAT rate; when nil, falls back to 25 %.
|
||||||
|
// This is the canonical EvalItem→CartItem conversion — shared by the
|
||||||
|
// stateless evaluator (req.toCart above) and any caller that needs to
|
||||||
|
// materialise an EvalItem into a real cart grain (e.g. the
|
||||||
|
// /promotions/evaluate-with-cart preview handler, which merges PDP
|
||||||
|
// request items into a deep-copied live grain so the engine sees the
|
||||||
|
// user's "what-if" line list on top of the cart's actual state). Keep
|
||||||
|
// the math identical to the inline conversion that used to live in
|
||||||
|
// req.toCart so the two paths can never drift.
|
||||||
|
//
|
||||||
|
// VatRate is the request rate in raw percent (external input); convert
|
||||||
|
// to the platform basis-point scale (× 100, rounded) at this boundary.
|
||||||
|
// Missing qty defaults to 1; missing VAT rate falls back to
|
||||||
|
// defaultVatRate(tp). Optional Category lands in ItemMeta.
|
||||||
|
func (it EvalItem) ToCartItem(tp cart.TaxProvider) *cart.CartItem {
|
||||||
|
qty := it.Quantity
|
||||||
|
if qty == 0 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
vat := int(math.Round(float64(it.VatRate) * 100))
|
||||||
|
if vat == 0 {
|
||||||
|
vat = defaultVatRate(tp)
|
||||||
|
}
|
||||||
|
out := &cart.CartItem{
|
||||||
|
Sku: it.Sku,
|
||||||
|
Quantity: qty,
|
||||||
|
Price: *cart.NewPriceFromIncVat(it.PriceIncVat, vat),
|
||||||
|
}
|
||||||
|
if it.Category != "" {
|
||||||
|
out.Meta = &cart.ItemMeta{Category: it.Category}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
|
// defaultVatRate returns the default VAT rate in basis points (2500 = 25%) from
|
||||||
// the provider, or 2500 if none is configured.
|
// the provider, or 2500 if none is configured.
|
||||||
func defaultVatRate(tp cart.TaxProvider) int {
|
func defaultVatRate(tp cart.TaxProvider) int {
|
||||||
@@ -114,8 +246,14 @@ func defaultVatRate(tp cart.TaxProvider) int {
|
|||||||
return tp.DefaultTaxRate("")
|
return tp.DefaultTaxRate("")
|
||||||
}
|
}
|
||||||
|
|
||||||
// contextOptions maps the optional customer/time fields onto context options.
|
// ContextOptions maps the optional customer/time fields onto context
|
||||||
func (req EvaluateRequest) contextOptions() []ContextOption {
|
// options. WithNow is only added when req.Now is explicitly set —
|
||||||
|
// callers that need a default "now" should append WithNow(time.Now())
|
||||||
|
// themselves (or pass no opts and let EvaluateAndApply default it).
|
||||||
|
// Exported so callers that build a context from a request (e.g. the
|
||||||
|
// /promotions/evaluate-with-cart preview handler) can use the same
|
||||||
|
// mapping the stateless Evaluate path uses, keeping the two in sync.
|
||||||
|
func (req EvaluateRequest) ContextOptions() []ContextOption {
|
||||||
var opts []ContextOption
|
var opts []ContextOption
|
||||||
if req.Now != nil {
|
if req.Now != nil {
|
||||||
opts = append(opts, WithNow(*req.Now))
|
opts = append(opts, WithNow(*req.Now))
|
||||||
|
|||||||
+46
-9
@@ -18,6 +18,7 @@ type Store struct {
|
|||||||
path string
|
path string
|
||||||
version int
|
version int
|
||||||
promotions []PromotionRule
|
promotions []PromotionRule
|
||||||
|
OnChange func(id string, isDelete bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore loads the state file at path (an absent file yields an empty store)
|
// NewStore loads the state file at path (an absent file yields an empty store)
|
||||||
@@ -72,40 +73,72 @@ func (s *Store) Upsert(rule PromotionRule) (replaced bool, err error) {
|
|||||||
if rule.ID == "" {
|
if rule.ID == "" {
|
||||||
return false, fmt.Errorf("promotion id is required")
|
return false, fmt.Errorf("promotion id is required")
|
||||||
}
|
}
|
||||||
|
var cb func(string, bool)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer func() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err == nil && cb != nil {
|
||||||
|
cb(rule.ID, false)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
cb = s.OnChange
|
||||||
|
|
||||||
for i := range s.promotions {
|
for i := range s.promotions {
|
||||||
if s.promotions[i].ID == rule.ID {
|
if s.promotions[i].ID == rule.ID {
|
||||||
s.promotions[i] = rule
|
s.promotions[i] = rule
|
||||||
return true, s.saveLocked()
|
replaced = true
|
||||||
|
err = s.saveLocked()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.promotions = append(s.promotions, rule)
|
s.promotions = append(s.promotions, rule)
|
||||||
return false, s.saveLocked()
|
replaced = false
|
||||||
|
err = s.saveLocked()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetStatus changes the status of a rule and persists. Returns false if no rule
|
// SetStatus changes the status of a rule and persists. Returns false if no rule
|
||||||
// with the id exists.
|
// with the id exists.
|
||||||
func (s *Store) SetStatus(id string, status PromotionStatus) (bool, error) {
|
func (s *Store) SetStatus(id string, status PromotionStatus) (found bool, err error) {
|
||||||
|
var cb func(string, bool)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer func() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err == nil && found && cb != nil {
|
||||||
|
cb(id, false)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
cb = s.OnChange
|
||||||
|
|
||||||
for i := range s.promotions {
|
for i := range s.promotions {
|
||||||
if s.promotions[i].ID == id {
|
if s.promotions[i].ID == id {
|
||||||
s.promotions[i].Status = status
|
s.promotions[i].Status = status
|
||||||
return true, s.saveLocked()
|
found = true
|
||||||
|
err = s.saveLocked()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes the rule with the id and persists. Returns false if not found.
|
// Delete removes the rule with the id and persists. Returns false if not found.
|
||||||
func (s *Store) Delete(id string) (bool, error) {
|
func (s *Store) Delete(id string) (found bool, err error) {
|
||||||
|
var cb func(string, bool)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer func() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err == nil && found && cb != nil {
|
||||||
|
cb(id, true)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
cb = s.OnChange
|
||||||
|
|
||||||
for i := range s.promotions {
|
for i := range s.promotions {
|
||||||
if s.promotions[i].ID == id {
|
if s.promotions[i].ID == id {
|
||||||
s.promotions = append(s.promotions[:i], s.promotions[i+1:]...)
|
s.promotions = append(s.promotions[:i], s.promotions[i+1:]...)
|
||||||
return true, s.saveLocked()
|
found = true
|
||||||
|
err = s.saveLocked()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -128,6 +161,10 @@ func (s *Store) saveLocked() error {
|
|||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
defer os.Remove(tmpName) // no-op once renamed
|
defer os.Remove(tmpName) // no-op once renamed
|
||||||
|
if err := tmp.Chmod(0644); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
if _, err := tmp.Write(data); err != nil {
|
if _, err := tmp.Write(data); err != nil {
|
||||||
tmp.Close()
|
tmp.Close()
|
||||||
return err
|
return err
|
||||||
|
|||||||
+16
-6
@@ -263,10 +263,16 @@ func (h *RemoteHost[V]) GetActorIds() []uint64 {
|
|||||||
return reply.GetIds()
|
return reply.GetIds()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64) {
|
func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64, lastChanges []int64) {
|
||||||
|
// lastChanges is parallel to uids and carries each grain's
|
||||||
|
// UnixNano lastChange stamp at the moment of broadcast. Receivers
|
||||||
|
// use it for first-spawn-wins arbitration on concurrent cold
|
||||||
|
// cache first touch; -1 entries signal "no local grain to back
|
||||||
|
// this id" and trigger the legacy "always accept remote" fallback.
|
||||||
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
|
_, err := h.controlClient.AnnounceOwnership(context.Background(), &messages.OwnershipAnnounce{
|
||||||
Host: ownerHost,
|
Host: ownerHost,
|
||||||
Ids: uids,
|
Ids: uids,
|
||||||
|
LastChanges: lastChanges,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ownership announce to %s failed: %v", h.host, err)
|
log.Printf("ownership announce to %s failed: %v", h.host, err)
|
||||||
@@ -276,10 +282,14 @@ func (h *RemoteHost[V]) AnnounceOwnership(ownerHost string, uids []uint64) {
|
|||||||
h.missedPings = 0
|
h.missedPings = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *RemoteHost[V]) AnnounceExpiry(uids []uint64) {
|
func (h *RemoteHost[V]) AnnounceExpiry(uids []uint64, lastChanges []int64) {
|
||||||
|
// lastChanges is a parallel UnixNano stamp slice; informational on
|
||||||
|
// this path (expiry is unilateral from the broadcaster), kept on
|
||||||
|
// the wire for symmetry with AnnounceOwnership.
|
||||||
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
|
_, err := h.controlClient.AnnounceExpiry(context.Background(), &messages.ExpiryAnnounce{
|
||||||
Host: h.host,
|
Host: h.host,
|
||||||
Ids: uids,
|
Ids: uids,
|
||||||
|
LastChanges: lastChanges,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("expiry announce to %s failed: %v", h.host, err)
|
log.Printf("expiry announce to %s failed: %v", h.host, err)
|
||||||
|
|||||||
@@ -95,6 +95,35 @@ message UpsertSubscriptionDetails {
|
|||||||
google.protobuf.Any data = 4;
|
google.protobuf.Any data = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CartType {
|
||||||
|
REGULAR = 0;
|
||||||
|
WISHLIST = 1;
|
||||||
|
OFFER = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SetCartType {
|
||||||
|
CartType type = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushToken is a generic, provider-agnostic push-delivery target. The cart layer
|
||||||
|
// only stores what it was given — actual delivery (FCM, APNs, Web Push) is the
|
||||||
|
// notifier's job (see pkg/cart/recovery.LoggingNotifier for the v0 contract).
|
||||||
|
//
|
||||||
|
// Platform is free-form (e.g. "fcm", "apns", "webpush") so callers can record
|
||||||
|
// multiple devices per cart without forcing a schema decision here.
|
||||||
|
message PushToken {
|
||||||
|
string platform = 1;
|
||||||
|
string token = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRecoveryContact attaches the contact bundle used by the abandoned-cart
|
||||||
|
// recovery flow. PUT-style: replaces the entire contact in one event — empty
|
||||||
|
// strings or empty token lists are persisted as "no contact".
|
||||||
|
message SetRecoveryContact {
|
||||||
|
string email = 1;
|
||||||
|
repeated PushToken push_tokens = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message Mutation {
|
message Mutation {
|
||||||
oneof type {
|
oneof type {
|
||||||
ClearCartRequest clear_cart = 1;
|
ClearCartRequest clear_cart = 1;
|
||||||
@@ -109,5 +138,8 @@ message Mutation {
|
|||||||
AddVoucher add_voucher = 20;
|
AddVoucher add_voucher = 20;
|
||||||
RemoveVoucher remove_voucher = 21;
|
RemoveVoucher remove_voucher = 21;
|
||||||
UpsertSubscriptionDetails upsert_subscription_details = 22;
|
UpsertSubscriptionDetails upsert_subscription_details = 22;
|
||||||
|
SetCartType set_cart_type = 23;
|
||||||
|
SetRecoveryContact set_recovery_contact = 24;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+419
-242
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.5
|
// protoc-gen-go v1.36.11
|
||||||
// protoc v7.35.0
|
// protoc v7.35.1
|
||||||
// source: cart.proto
|
// source: cart.proto
|
||||||
|
|
||||||
package cart_messages
|
package cart_messages
|
||||||
@@ -23,6 +23,55 @@ const (
|
|||||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type CartType int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
CartType_REGULAR CartType = 0
|
||||||
|
CartType_WISHLIST CartType = 1
|
||||||
|
CartType_OFFER CartType = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for CartType.
|
||||||
|
var (
|
||||||
|
CartType_name = map[int32]string{
|
||||||
|
0: "REGULAR",
|
||||||
|
1: "WISHLIST",
|
||||||
|
2: "OFFER",
|
||||||
|
}
|
||||||
|
CartType_value = map[string]int32{
|
||||||
|
"REGULAR": 0,
|
||||||
|
"WISHLIST": 1,
|
||||||
|
"OFFER": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x CartType) Enum() *CartType {
|
||||||
|
p := new(CartType)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x CartType) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CartType) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_cart_proto_enumTypes[0].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CartType) Type() protoreflect.EnumType {
|
||||||
|
return &file_cart_proto_enumTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x CartType) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use CartType.Descriptor instead.
|
||||||
|
func (CartType) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_cart_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
type ClearCartRequest struct {
|
type ClearCartRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
@@ -878,6 +927,163 @@ func (x *UpsertSubscriptionDetails) GetData() *anypb.Any {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetCartType struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Type CartType `protobuf:"varint,1,opt,name=type,proto3,enum=cart_messages.CartType" json:"type,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SetCartType) Reset() {
|
||||||
|
*x = SetCartType{}
|
||||||
|
mi := &file_cart_proto_msgTypes[12]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SetCartType) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SetCartType) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *SetCartType) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_cart_proto_msgTypes[12]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use SetCartType.ProtoReflect.Descriptor instead.
|
||||||
|
func (*SetCartType) Descriptor() ([]byte, []int) {
|
||||||
|
return file_cart_proto_rawDescGZIP(), []int{12}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SetCartType) GetType() CartType {
|
||||||
|
if x != nil {
|
||||||
|
return x.Type
|
||||||
|
}
|
||||||
|
return CartType_REGULAR
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushToken is a generic, provider-agnostic push-delivery target. The cart layer
|
||||||
|
// only stores what it was given — actual delivery (FCM, APNs, Web Push) is the
|
||||||
|
// notifier's job (see pkg/cart/recovery.LoggingNotifier for the v0 contract).
|
||||||
|
//
|
||||||
|
// Platform is free-form (e.g. "fcm", "apns", "webpush") so callers can record
|
||||||
|
// multiple devices per cart without forcing a schema decision here.
|
||||||
|
type PushToken struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform,omitempty"`
|
||||||
|
Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PushToken) Reset() {
|
||||||
|
*x = PushToken{}
|
||||||
|
mi := &file_cart_proto_msgTypes[13]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PushToken) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PushToken) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *PushToken) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_cart_proto_msgTypes[13]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use PushToken.ProtoReflect.Descriptor instead.
|
||||||
|
func (*PushToken) Descriptor() ([]byte, []int) {
|
||||||
|
return file_cart_proto_rawDescGZIP(), []int{13}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PushToken) GetPlatform() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Platform
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PushToken) GetToken() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Token
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRecoveryContact attaches the contact bundle used by the abandoned-cart
|
||||||
|
// recovery flow. PUT-style: replaces the entire contact in one event — empty
|
||||||
|
// strings or empty token lists are persisted as "no contact".
|
||||||
|
type SetRecoveryContact struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"`
|
||||||
|
PushTokens []*PushToken `protobuf:"bytes,2,rep,name=push_tokens,json=pushTokens,proto3" json:"push_tokens,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SetRecoveryContact) Reset() {
|
||||||
|
*x = SetRecoveryContact{}
|
||||||
|
mi := &file_cart_proto_msgTypes[14]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SetRecoveryContact) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*SetRecoveryContact) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *SetRecoveryContact) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_cart_proto_msgTypes[14]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use SetRecoveryContact.ProtoReflect.Descriptor instead.
|
||||||
|
func (*SetRecoveryContact) Descriptor() ([]byte, []int) {
|
||||||
|
return file_cart_proto_rawDescGZIP(), []int{14}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SetRecoveryContact) GetEmail() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Email
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *SetRecoveryContact) GetPushTokens() []*PushToken {
|
||||||
|
if x != nil {
|
||||||
|
return x.PushTokens
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type Mutation struct {
|
type Mutation struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
// Types that are valid to be assigned to Type:
|
// Types that are valid to be assigned to Type:
|
||||||
@@ -894,6 +1100,8 @@ type Mutation struct {
|
|||||||
// *Mutation_AddVoucher
|
// *Mutation_AddVoucher
|
||||||
// *Mutation_RemoveVoucher
|
// *Mutation_RemoveVoucher
|
||||||
// *Mutation_UpsertSubscriptionDetails
|
// *Mutation_UpsertSubscriptionDetails
|
||||||
|
// *Mutation_SetCartType
|
||||||
|
// *Mutation_SetRecoveryContact
|
||||||
Type isMutation_Type `protobuf_oneof:"type"`
|
Type isMutation_Type `protobuf_oneof:"type"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -901,7 +1109,7 @@ type Mutation struct {
|
|||||||
|
|
||||||
func (x *Mutation) Reset() {
|
func (x *Mutation) Reset() {
|
||||||
*x = Mutation{}
|
*x = Mutation{}
|
||||||
mi := &file_cart_proto_msgTypes[12]
|
mi := &file_cart_proto_msgTypes[15]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@@ -913,7 +1121,7 @@ func (x *Mutation) String() string {
|
|||||||
func (*Mutation) ProtoMessage() {}
|
func (*Mutation) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Mutation) ProtoReflect() protoreflect.Message {
|
func (x *Mutation) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_cart_proto_msgTypes[12]
|
mi := &file_cart_proto_msgTypes[15]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@@ -926,7 +1134,7 @@ func (x *Mutation) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Mutation.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Mutation.ProtoReflect.Descriptor instead.
|
||||||
func (*Mutation) Descriptor() ([]byte, []int) {
|
func (*Mutation) Descriptor() ([]byte, []int) {
|
||||||
return file_cart_proto_rawDescGZIP(), []int{12}
|
return file_cart_proto_rawDescGZIP(), []int{15}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Mutation) GetType() isMutation_Type {
|
func (x *Mutation) GetType() isMutation_Type {
|
||||||
@@ -1044,6 +1252,24 @@ func (x *Mutation) GetUpsertSubscriptionDetails() *UpsertSubscriptionDetails {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *Mutation) GetSetCartType() *SetCartType {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Type.(*Mutation_SetCartType); ok {
|
||||||
|
return x.SetCartType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Mutation) GetSetRecoveryContact() *SetRecoveryContact {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Type.(*Mutation_SetRecoveryContact); ok {
|
||||||
|
return x.SetRecoveryContact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type isMutation_Type interface {
|
type isMutation_Type interface {
|
||||||
isMutation_Type()
|
isMutation_Type()
|
||||||
}
|
}
|
||||||
@@ -1096,6 +1322,14 @@ type Mutation_UpsertSubscriptionDetails struct {
|
|||||||
UpsertSubscriptionDetails *UpsertSubscriptionDetails `protobuf:"bytes,22,opt,name=upsert_subscription_details,json=upsertSubscriptionDetails,proto3,oneof"`
|
UpsertSubscriptionDetails *UpsertSubscriptionDetails `protobuf:"bytes,22,opt,name=upsert_subscription_details,json=upsertSubscriptionDetails,proto3,oneof"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Mutation_SetCartType struct {
|
||||||
|
SetCartType *SetCartType `protobuf:"bytes,23,opt,name=set_cart_type,json=setCartType,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation_SetRecoveryContact struct {
|
||||||
|
SetRecoveryContact *SetRecoveryContact `protobuf:"bytes,24,opt,name=set_recovery_contact,json=setRecoveryContact,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
func (*Mutation_ClearCart) isMutation_Type() {}
|
func (*Mutation_ClearCart) isMutation_Type() {}
|
||||||
|
|
||||||
func (*Mutation_AddItem) isMutation_Type() {}
|
func (*Mutation_AddItem) isMutation_Type() {}
|
||||||
@@ -1120,203 +1354,134 @@ func (*Mutation_RemoveVoucher) isMutation_Type() {}
|
|||||||
|
|
||||||
func (*Mutation_UpsertSubscriptionDetails) isMutation_Type() {}
|
func (*Mutation_UpsertSubscriptionDetails) isMutation_Type() {}
|
||||||
|
|
||||||
|
func (*Mutation_SetCartType) isMutation_Type() {}
|
||||||
|
|
||||||
|
func (*Mutation_SetRecoveryContact) isMutation_Type() {}
|
||||||
|
|
||||||
var File_cart_proto protoreflect.FileDescriptor
|
var File_cart_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
var file_cart_proto_rawDesc = string([]byte{
|
const file_cart_proto_rawDesc = "" +
|
||||||
0x0a, 0x0a, 0x63, 0x61, 0x72, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x63, 0x61,
|
"\n" +
|
||||||
0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67, 0x6f, 0x6f,
|
"\n" +
|
||||||
0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79,
|
"cart.proto\x12\rcart_messages\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x12\n" +
|
||||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70,
|
"\x10ClearCartRequest\"\xaa\b\n" +
|
||||||
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
|
"\aAddItem\x12\x17\n" +
|
||||||
0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x12, 0x0a, 0x10, 0x43, 0x6c, 0x65, 0x61, 0x72,
|
"\aitem_id\x18\x01 \x01(\rR\x06itemId\x12\x1a\n" +
|
||||||
0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xaa, 0x08, 0x0a, 0x07,
|
"\bquantity\x18\x02 \x01(\x05R\bquantity\x12\x14\n" +
|
||||||
0x41, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x69, 0x74, 0x65, 0x6d, 0x5f,
|
"\x05price\x18\x03 \x01(\x03R\x05price\x12\x1a\n" +
|
||||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64,
|
"\borgPrice\x18\t \x01(\x03R\borgPrice\x12\x10\n" +
|
||||||
0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01,
|
"\x03sku\x18\x04 \x01(\tR\x03sku\x12\x12\n" +
|
||||||
0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x14, 0x0a, 0x05,
|
"\x04name\x18\x05 \x01(\tR\x04name\x12\x14\n" +
|
||||||
0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x70, 0x72, 0x69,
|
"\x05image\x18\x06 \x01(\tR\x05image\x12\x14\n" +
|
||||||
0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x18, 0x09,
|
"\x05stock\x18\a \x01(\x05R\x05stock\x12\x10\n" +
|
||||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x6f, 0x72, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12, 0x10,
|
"\x03tax\x18\b \x01(\x05R\x03tax\x12\x14\n" +
|
||||||
0x0a, 0x03, 0x73, 0x6b, 0x75, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x73, 0x6b, 0x75,
|
"\x05brand\x18\r \x01(\tR\x05brand\x12\x1a\n" +
|
||||||
0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
"\bcategory\x18\x0e \x01(\tR\bcategory\x12\x1c\n" +
|
||||||
0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x06, 0x20,
|
"\tcategory2\x18\x0f \x01(\tR\tcategory2\x12\x1c\n" +
|
||||||
0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74,
|
"\tcategory3\x18\x10 \x01(\tR\tcategory3\x12\x1c\n" +
|
||||||
0x6f, 0x63, 0x6b, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x73, 0x74, 0x6f, 0x63, 0x6b,
|
"\tcategory4\x18\x11 \x01(\tR\tcategory4\x12\x1c\n" +
|
||||||
0x12, 0x10, 0x0a, 0x03, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x05, 0x52, 0x03, 0x74,
|
"\tcategory5\x18\x12 \x01(\tR\tcategory5\x12\x1e\n" +
|
||||||
0x61, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28,
|
"\n" +
|
||||||
0x09, 0x52, 0x05, 0x62, 0x72, 0x61, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x74, 0x65,
|
"disclaimer\x18\n" +
|
||||||
0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x74, 0x65,
|
" \x01(\tR\n" +
|
||||||
0x67, 0x6f, 0x72, 0x79, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
|
"disclaimer\x12 \n" +
|
||||||
0x32, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72,
|
"\varticleType\x18\v \x01(\tR\varticleType\x12\x1a\n" +
|
||||||
0x79, 0x32, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x33, 0x18,
|
"\bsellerId\x18\x13 \x01(\tR\bsellerId\x12\x1e\n" +
|
||||||
0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x33,
|
"\n" +
|
||||||
0x12, 0x1c, 0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34, 0x18, 0x11, 0x20,
|
"sellerName\x18\x14 \x01(\tR\n" +
|
||||||
0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x34, 0x12, 0x1c,
|
"sellerName\x12\x18\n" +
|
||||||
0x0a, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x18, 0x12, 0x20, 0x01, 0x28,
|
"\acountry\x18\x15 \x01(\tR\acountry\x12\x1e\n" +
|
||||||
0x09, 0x52, 0x09, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x35, 0x12, 0x1e, 0x0a, 0x0a,
|
"\n" +
|
||||||
0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09,
|
"saleStatus\x18\x18 \x01(\tR\n" +
|
||||||
0x52, 0x0a, 0x64, 0x69, 0x73, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x65, 0x72, 0x12, 0x20, 0x0a, 0x0b,
|
"saleStatus\x12\x1b\n" +
|
||||||
0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28,
|
"\x06outlet\x18\f \x01(\tH\x00R\x06outlet\x88\x01\x01\x12\x1d\n" +
|
||||||
0x09, 0x52, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x63, 0x6c, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a,
|
"\astoreId\x18\x16 \x01(\tH\x01R\astoreId\x88\x01\x01\x12\x1f\n" +
|
||||||
0x0a, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x18, 0x13, 0x20, 0x01, 0x28, 0x09,
|
"\bparentId\x18\x17 \x01(\rH\x02R\bparentId\x88\x01\x01\x12\x10\n" +
|
||||||
0x52, 0x08, 0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x65,
|
"\x03cgm\x18\x19 \x01(\tR\x03cgm\x12O\n" +
|
||||||
0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a,
|
"\x12reservationEndTime\x18\x1a \x01(\v2\x1a.google.protobuf.TimestampH\x03R\x12reservationEndTime\x88\x01\x01\x12\x1d\n" +
|
||||||
0x73, 0x65, 0x6c, 0x6c, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f,
|
"\n" +
|
||||||
0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
"extra_json\x18\x1b \x01(\fR\textraJson\x12M\n" +
|
||||||
0x6e, 0x74, 0x72, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74, 0x61, 0x74,
|
"\rcustom_fields\x18\x1c \x03(\v2(.cart_messages.AddItem.CustomFieldsEntryR\fcustomFields\x12+\n" +
|
||||||
0x75, 0x73, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x61, 0x6c, 0x65, 0x53, 0x74,
|
"\x11inventory_tracked\x18\x1d \x01(\bR\x10inventoryTracked\x12\x1b\n" +
|
||||||
0x61, 0x74, 0x75, 0x73, 0x12, 0x1b, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x18, 0x0c,
|
"\tdrop_ship\x18\x1e \x01(\bR\bdropShip\x1a?\n" +
|
||||||
0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x88, 0x01,
|
"\x11CustomFieldsEntry\x12\x10\n" +
|
||||||
0x01, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x18, 0x16, 0x20, 0x01,
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x49, 0x64, 0x88, 0x01, 0x01,
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\t\n" +
|
||||||
0x12, 0x1f, 0x0a, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x17, 0x20, 0x01,
|
"\a_outletB\n" +
|
||||||
0x28, 0x0d, 0x48, 0x02, 0x52, 0x08, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01,
|
"\n" +
|
||||||
0x01, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x67, 0x6d, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
"\b_storeIdB\v\n" +
|
||||||
0x63, 0x67, 0x6d, 0x12, 0x4f, 0x0a, 0x12, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69,
|
"\t_parentIdB\x15\n" +
|
||||||
0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
"\x13_reservationEndTime\"\x1c\n" +
|
||||||
0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
|
"\n" +
|
||||||
0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x03, 0x52, 0x12, 0x72,
|
"RemoveItem\x12\x0e\n" +
|
||||||
0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d,
|
"\x02Id\x18\x01 \x01(\rR\x02Id\"<\n" +
|
||||||
0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x72, 0x61, 0x5f, 0x6a, 0x73,
|
"\x0eChangeQuantity\x12\x0e\n" +
|
||||||
0x6f, 0x6e, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x65, 0x78, 0x74, 0x72, 0x61, 0x4a,
|
"\x02Id\x18\x01 \x01(\rR\x02Id\x12\x1a\n" +
|
||||||
0x73, 0x6f, 0x6e, 0x12, 0x4d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69,
|
"\bquantity\x18\x02 \x01(\x05R\bquantity\"#\n" +
|
||||||
0x65, 0x6c, 0x64, 0x73, 0x18, 0x1c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72,
|
"\tSetUserId\x12\x16\n" +
|
||||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x49, 0x74,
|
"\x06userId\x18\x01 \x01(\tR\x06userId\"O\n" +
|
||||||
0x65, 0x6d, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45,
|
"\x0fLineItemMarking\x12\x0e\n" +
|
||||||
0x6e, 0x74, 0x72, 0x79, 0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
"\x02id\x18\x01 \x01(\rR\x02id\x12\x12\n" +
|
||||||
0x64, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x69, 0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x5f,
|
"\x04type\x18\x02 \x01(\rR\x04type\x12\x18\n" +
|
||||||
0x74, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69,
|
"\amarking\x18\x03 \x01(\tR\amarking\"'\n" +
|
||||||
0x6e, 0x76, 0x65, 0x6e, 0x74, 0x6f, 0x72, 0x79, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x12,
|
"\x15RemoveLineItemMarking\x12\x0e\n" +
|
||||||
0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x1e, 0x20, 0x01,
|
"\x02id\x18\x01 \x01(\rR\x02id\"\xc9\x01\n" +
|
||||||
0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 0x1a, 0x3f, 0x0a, 0x11,
|
"\x17SetLineItemCustomFields\x12\x0e\n" +
|
||||||
0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72,
|
"\x02id\x18\x01 \x01(\rR\x02id\x12]\n" +
|
||||||
0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
|
"\rcustom_fields\x18\x02 \x03(\v28.cart_messages.SetLineItemCustomFields.CustomFieldsEntryR\fcustomFields\x1a?\n" +
|
||||||
0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01,
|
"\x11CustomFieldsEntry\x12\x10\n" +
|
||||||
0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42, 0x09, 0x0a,
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
0x07, 0x5f, 0x6f, 0x75, 0x74, 0x6c, 0x65, 0x74, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x73, 0x74, 0x6f,
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"q\n" +
|
||||||
0x72, 0x65, 0x49, 0x64, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x49,
|
"\x11SubscriptionAdded\x12\x16\n" +
|
||||||
0x64, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x72, 0x76, 0x61, 0x74, 0x69, 0x6f,
|
"\x06itemId\x18\x01 \x01(\rR\x06itemId\x12\x1c\n" +
|
||||||
0x6e, 0x45, 0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x1c, 0x0a, 0x0a, 0x52, 0x65, 0x6d, 0x6f,
|
"\tdetailsId\x18\x03 \x01(\tR\tdetailsId\x12&\n" +
|
||||||
0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
|
"\x0eorderReference\x18\x04 \x01(\tR\x0eorderReference\"|\n" +
|
||||||
0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x22, 0x3c, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
"\n" +
|
||||||
0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x64, 0x18, 0x01,
|
"AddVoucher\x12\x12\n" +
|
||||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
"\x04code\x18\x01 \x01(\tR\x04code\x12\x14\n" +
|
||||||
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
"\x05value\x18\x02 \x01(\x03R\x05value\x12\"\n" +
|
||||||
0x74, 0x69, 0x74, 0x79, 0x22, 0x23, 0x0a, 0x09, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
|
"\fvoucherRules\x18\x03 \x03(\tR\fvoucherRules\x12 \n" +
|
||||||
0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
"\vdescription\x18\x04 \x01(\tR\vdescription\"\x1f\n" +
|
||||||
0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x4f, 0x0a, 0x0f, 0x4c, 0x69, 0x6e,
|
"\rRemoveVoucher\x12\x0e\n" +
|
||||||
0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02,
|
"\x02id\x18\x01 \x01(\rR\x02id\"\xa7\x01\n" +
|
||||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04,
|
"\x19UpsertSubscriptionDetails\x12\x13\n" +
|
||||||
0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65,
|
"\x02id\x18\x01 \x01(\tH\x00R\x02id\x88\x01\x01\x12\"\n" +
|
||||||
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28,
|
"\fofferingCode\x18\x02 \x01(\tR\fofferingCode\x12 \n" +
|
||||||
0x09, 0x52, 0x07, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x22, 0x27, 0x0a, 0x15, 0x52, 0x65,
|
"\vsigningType\x18\x03 \x01(\tR\vsigningType\x12(\n" +
|
||||||
0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b,
|
"\x04data\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\x04dataB\x05\n" +
|
||||||
0x69, 0x6e, 0x67, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52,
|
"\x03_id\":\n" +
|
||||||
0x02, 0x69, 0x64, 0x22, 0xc9, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49,
|
"\vSetCartType\x12+\n" +
|
||||||
0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12,
|
"\x04type\x18\x01 \x01(\x0e2\x17.cart_messages.CartTypeR\x04type\"=\n" +
|
||||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
|
"\tPushToken\x12\x1a\n" +
|
||||||
0x5d, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73,
|
"\bplatform\x18\x01 \x01(\tR\bplatform\x12\x14\n" +
|
||||||
0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
"\x05token\x18\x02 \x01(\tR\x05token\"e\n" +
|
||||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
"\x12SetRecoveryContact\x12\x14\n" +
|
||||||
0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x2e, 0x43,
|
"\x05email\x18\x01 \x01(\tR\x05email\x129\n" +
|
||||||
0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79,
|
"\vpush_tokens\x18\x02 \x03(\v2\x18.cart_messages.PushTokenR\n" +
|
||||||
0x52, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x1a, 0x3f,
|
"pushTokens\"\xc1\b\n" +
|
||||||
0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x45, 0x6e,
|
"\bMutation\x12@\n" +
|
||||||
0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
"\n" +
|
||||||
0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
|
"clear_cart\x18\x01 \x01(\v2\x1f.cart_messages.ClearCartRequestH\x00R\tclearCart\x123\n" +
|
||||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22,
|
"\badd_item\x18\x02 \x01(\v2\x16.cart_messages.AddItemH\x00R\aaddItem\x12<\n" +
|
||||||
0x71, 0x0a, 0x11, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41,
|
"\vremove_item\x18\x03 \x01(\v2\x19.cart_messages.RemoveItemH\x00R\n" +
|
||||||
0x64, 0x64, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x18, 0x01,
|
"removeItem\x12H\n" +
|
||||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x69, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09,
|
"\x0fchange_quantity\x18\x04 \x01(\v2\x1d.cart_messages.ChangeQuantityH\x00R\x0echangeQuantity\x12:\n" +
|
||||||
0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
"\vset_user_id\x18\x05 \x01(\v2\x18.cart_messages.SetUserIdH\x00R\tsetUserId\x12L\n" +
|
||||||
0x09, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72,
|
"\x11line_item_marking\x18\x06 \x01(\v2\x1e.cart_messages.LineItemMarkingH\x00R\x0flineItemMarking\x12_\n" +
|
||||||
0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01,
|
"\x18remove_line_item_marking\x18\a \x01(\v2$.cart_messages.RemoveLineItemMarkingH\x00R\x15removeLineItemMarking\x12Q\n" +
|
||||||
0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
"\x12subscription_added\x18\b \x01(\v2 .cart_messages.SubscriptionAddedH\x00R\x11subscriptionAdded\x12f\n" +
|
||||||
0x63, 0x65, 0x22, 0x7c, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72,
|
"\x1bset_line_item_custom_fields\x18\t \x01(\v2&.cart_messages.SetLineItemCustomFieldsH\x00R\x17setLineItemCustomFields\x12<\n" +
|
||||||
0x12, 0x12, 0x0a, 0x04, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
"\vadd_voucher\x18\x14 \x01(\v2\x19.cart_messages.AddVoucherH\x00R\n" +
|
||||||
0x63, 0x6f, 0x64, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
|
"addVoucher\x12E\n" +
|
||||||
0x01, 0x28, 0x03, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x76, 0x6f,
|
"\x0eremove_voucher\x18\x15 \x01(\v2\x1c.cart_messages.RemoveVoucherH\x00R\rremoveVoucher\x12j\n" +
|
||||||
0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09,
|
"\x1bupsert_subscription_details\x18\x16 \x01(\v2(.cart_messages.UpsertSubscriptionDetailsH\x00R\x19upsertSubscriptionDetails\x12@\n" +
|
||||||
0x52, 0x0c, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x20,
|
"\rset_cart_type\x18\x17 \x01(\v2\x1a.cart_messages.SetCartTypeH\x00R\vsetCartType\x12U\n" +
|
||||||
0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20,
|
"\x14set_recovery_contact\x18\x18 \x01(\v2!.cart_messages.SetRecoveryContactH\x00R\x12setRecoveryContactB\x06\n" +
|
||||||
0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
"\x04type*0\n" +
|
||||||
0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
"\bCartType\x12\v\n" +
|
||||||
0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69,
|
"\aREGULAR\x10\x00\x12\f\n" +
|
||||||
0x64, 0x22, 0xa7, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73,
|
"\bWISHLIST\x10\x01\x12\t\n" +
|
||||||
0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12,
|
"\x05OFFER\x10\x02B9Z7git.k6n.net/mats/go-cart-actor/proto/cart;cart_messagesb\x06proto3"
|
||||||
0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x02, 0x69,
|
|
||||||
0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0c, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x69, 0x6e, 0x67,
|
|
||||||
0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6f, 0x66, 0x66, 0x65,
|
|
||||||
0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e,
|
|
||||||
0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73,
|
|
||||||
0x69, 0x67, 0x6e, 0x69, 0x6e, 0x67, 0x54, 0x79, 0x70, 0x65, 0x12, 0x28, 0x0a, 0x04, 0x64, 0x61,
|
|
||||||
0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
|
|
||||||
0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x04,
|
|
||||||
0x64, 0x61, 0x74, 0x61, 0x42, 0x05, 0x0a, 0x03, 0x5f, 0x69, 0x64, 0x22, 0xa8, 0x07, 0x0a, 0x08,
|
|
||||||
0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x40, 0x0a, 0x0a, 0x63, 0x6c, 0x65, 0x61,
|
|
||||||
0x72, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63,
|
|
||||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x65,
|
|
||||||
0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
|
|
||||||
0x09, 0x63, 0x6c, 0x65, 0x61, 0x72, 0x43, 0x61, 0x72, 0x74, 0x12, 0x33, 0x0a, 0x08, 0x61, 0x64,
|
|
||||||
0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x63,
|
|
||||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
|
|
||||||
0x49, 0x74, 0x65, 0x6d, 0x48, 0x00, 0x52, 0x07, 0x61, 0x64, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x12,
|
|
||||||
0x3c, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x03,
|
|
||||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
|
||||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x48,
|
|
||||||
0x00, 0x52, 0x0a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x48, 0x0a,
|
|
||||||
0x0f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79,
|
|
||||||
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65,
|
|
||||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51, 0x75, 0x61,
|
|
||||||
0x6e, 0x74, 0x69, 0x74, 0x79, 0x48, 0x00, 0x52, 0x0e, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x51,
|
|
||||||
0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x3a, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x75,
|
|
||||||
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63,
|
|
||||||
0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
|
||||||
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x48, 0x00, 0x52, 0x09, 0x73, 0x65, 0x74, 0x55, 0x73, 0x65,
|
|
||||||
0x72, 0x49, 0x64, 0x12, 0x4c, 0x0a, 0x11, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d,
|
|
||||||
0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
|
|
||||||
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c,
|
|
||||||
0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00,
|
|
||||||
0x52, 0x0f, 0x6c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e,
|
|
||||||
0x67, 0x12, 0x5f, 0x0a, 0x18, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x6c, 0x69, 0x6e, 0x65,
|
|
||||||
0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x18, 0x07, 0x20,
|
|
||||||
0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
|
||||||
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74,
|
|
||||||
0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x15, 0x72, 0x65, 0x6d,
|
|
||||||
0x6f, 0x76, 0x65, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x4d, 0x61, 0x72, 0x6b, 0x69,
|
|
||||||
0x6e, 0x67, 0x12, 0x51, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
|
|
||||||
0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20,
|
|
||||||
0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53,
|
|
||||||
0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x65, 0x64,
|
|
||||||
0x48, 0x00, 0x52, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e,
|
|
||||||
0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x66, 0x0a, 0x1b, 0x73, 0x65, 0x74, 0x5f, 0x6c, 0x69, 0x6e,
|
|
||||||
0x65, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x66, 0x69,
|
|
||||||
0x65, 0x6c, 0x64, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x61, 0x72,
|
|
||||||
0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x4c, 0x69,
|
|
||||||
0x6e, 0x65, 0x49, 0x74, 0x65, 0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c,
|
|
||||||
0x64, 0x73, 0x48, 0x00, 0x52, 0x17, 0x73, 0x65, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x49, 0x74, 0x65,
|
|
||||||
0x6d, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x12, 0x3c, 0x0a,
|
|
||||||
0x0b, 0x61, 0x64, 0x64, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x14, 0x20, 0x01,
|
|
||||||
0x28, 0x0b, 0x32, 0x19, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
|
||||||
0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x48, 0x00, 0x52,
|
|
||||||
0x0a, 0x61, 0x64, 0x64, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x12, 0x45, 0x0a, 0x0e, 0x72,
|
|
||||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x76, 0x6f, 0x75, 0x63, 0x68, 0x65, 0x72, 0x18, 0x15, 0x20,
|
|
||||||
0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
|
||||||
0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68, 0x65,
|
|
||||||
0x72, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x56, 0x6f, 0x75, 0x63, 0x68,
|
|
||||||
0x65, 0x72, 0x12, 0x6a, 0x0a, 0x1b, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x5f, 0x73, 0x75, 0x62,
|
|
||||||
0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x64, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
|
||||||
0x73, 0x18, 0x16, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d,
|
|
||||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75,
|
|
||||||
0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c,
|
|
||||||
0x73, 0x48, 0x00, 0x52, 0x19, 0x75, 0x70, 0x73, 0x65, 0x72, 0x74, 0x53, 0x75, 0x62, 0x73, 0x63,
|
|
||||||
0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x42, 0x06,
|
|
||||||
0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x39, 0x5a, 0x37, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36,
|
|
||||||
0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61,
|
|
||||||
0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63,
|
|
||||||
0x61, 0x72, 0x74, 0x3b, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
|
||||||
0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
|
||||||
})
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_cart_proto_rawDescOnce sync.Once
|
file_cart_proto_rawDescOnce sync.Once
|
||||||
@@ -1330,48 +1495,57 @@ func file_cart_proto_rawDescGZIP() []byte {
|
|||||||
return file_cart_proto_rawDescData
|
return file_cart_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
|
var file_cart_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
|
var file_cart_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
|
||||||
var file_cart_proto_goTypes = []any{
|
var file_cart_proto_goTypes = []any{
|
||||||
(*ClearCartRequest)(nil), // 0: cart_messages.ClearCartRequest
|
(CartType)(0), // 0: cart_messages.CartType
|
||||||
(*AddItem)(nil), // 1: cart_messages.AddItem
|
(*ClearCartRequest)(nil), // 1: cart_messages.ClearCartRequest
|
||||||
(*RemoveItem)(nil), // 2: cart_messages.RemoveItem
|
(*AddItem)(nil), // 2: cart_messages.AddItem
|
||||||
(*ChangeQuantity)(nil), // 3: cart_messages.ChangeQuantity
|
(*RemoveItem)(nil), // 3: cart_messages.RemoveItem
|
||||||
(*SetUserId)(nil), // 4: cart_messages.SetUserId
|
(*ChangeQuantity)(nil), // 4: cart_messages.ChangeQuantity
|
||||||
(*LineItemMarking)(nil), // 5: cart_messages.LineItemMarking
|
(*SetUserId)(nil), // 5: cart_messages.SetUserId
|
||||||
(*RemoveLineItemMarking)(nil), // 6: cart_messages.RemoveLineItemMarking
|
(*LineItemMarking)(nil), // 6: cart_messages.LineItemMarking
|
||||||
(*SetLineItemCustomFields)(nil), // 7: cart_messages.SetLineItemCustomFields
|
(*RemoveLineItemMarking)(nil), // 7: cart_messages.RemoveLineItemMarking
|
||||||
(*SubscriptionAdded)(nil), // 8: cart_messages.SubscriptionAdded
|
(*SetLineItemCustomFields)(nil), // 8: cart_messages.SetLineItemCustomFields
|
||||||
(*AddVoucher)(nil), // 9: cart_messages.AddVoucher
|
(*SubscriptionAdded)(nil), // 9: cart_messages.SubscriptionAdded
|
||||||
(*RemoveVoucher)(nil), // 10: cart_messages.RemoveVoucher
|
(*AddVoucher)(nil), // 10: cart_messages.AddVoucher
|
||||||
(*UpsertSubscriptionDetails)(nil), // 11: cart_messages.UpsertSubscriptionDetails
|
(*RemoveVoucher)(nil), // 11: cart_messages.RemoveVoucher
|
||||||
(*Mutation)(nil), // 12: cart_messages.Mutation
|
(*UpsertSubscriptionDetails)(nil), // 12: cart_messages.UpsertSubscriptionDetails
|
||||||
nil, // 13: cart_messages.AddItem.CustomFieldsEntry
|
(*SetCartType)(nil), // 13: cart_messages.SetCartType
|
||||||
nil, // 14: cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
(*PushToken)(nil), // 14: cart_messages.PushToken
|
||||||
(*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp
|
(*SetRecoveryContact)(nil), // 15: cart_messages.SetRecoveryContact
|
||||||
(*anypb.Any)(nil), // 16: google.protobuf.Any
|
(*Mutation)(nil), // 16: cart_messages.Mutation
|
||||||
|
nil, // 17: cart_messages.AddItem.CustomFieldsEntry
|
||||||
|
nil, // 18: cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
||||||
|
(*timestamppb.Timestamp)(nil), // 19: google.protobuf.Timestamp
|
||||||
|
(*anypb.Any)(nil), // 20: google.protobuf.Any
|
||||||
}
|
}
|
||||||
var file_cart_proto_depIdxs = []int32{
|
var file_cart_proto_depIdxs = []int32{
|
||||||
15, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp
|
19, // 0: cart_messages.AddItem.reservationEndTime:type_name -> google.protobuf.Timestamp
|
||||||
13, // 1: cart_messages.AddItem.custom_fields:type_name -> cart_messages.AddItem.CustomFieldsEntry
|
17, // 1: cart_messages.AddItem.custom_fields:type_name -> cart_messages.AddItem.CustomFieldsEntry
|
||||||
14, // 2: cart_messages.SetLineItemCustomFields.custom_fields:type_name -> cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
18, // 2: cart_messages.SetLineItemCustomFields.custom_fields:type_name -> cart_messages.SetLineItemCustomFields.CustomFieldsEntry
|
||||||
16, // 3: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
|
20, // 3: cart_messages.UpsertSubscriptionDetails.data:type_name -> google.protobuf.Any
|
||||||
0, // 4: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest
|
0, // 4: cart_messages.SetCartType.type:type_name -> cart_messages.CartType
|
||||||
1, // 5: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem
|
14, // 5: cart_messages.SetRecoveryContact.push_tokens:type_name -> cart_messages.PushToken
|
||||||
2, // 6: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem
|
1, // 6: cart_messages.Mutation.clear_cart:type_name -> cart_messages.ClearCartRequest
|
||||||
3, // 7: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity
|
2, // 7: cart_messages.Mutation.add_item:type_name -> cart_messages.AddItem
|
||||||
4, // 8: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId
|
3, // 8: cart_messages.Mutation.remove_item:type_name -> cart_messages.RemoveItem
|
||||||
5, // 9: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking
|
4, // 9: cart_messages.Mutation.change_quantity:type_name -> cart_messages.ChangeQuantity
|
||||||
6, // 10: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking
|
5, // 10: cart_messages.Mutation.set_user_id:type_name -> cart_messages.SetUserId
|
||||||
8, // 11: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded
|
6, // 11: cart_messages.Mutation.line_item_marking:type_name -> cart_messages.LineItemMarking
|
||||||
7, // 12: cart_messages.Mutation.set_line_item_custom_fields:type_name -> cart_messages.SetLineItemCustomFields
|
7, // 12: cart_messages.Mutation.remove_line_item_marking:type_name -> cart_messages.RemoveLineItemMarking
|
||||||
9, // 13: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher
|
9, // 13: cart_messages.Mutation.subscription_added:type_name -> cart_messages.SubscriptionAdded
|
||||||
10, // 14: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher
|
8, // 14: cart_messages.Mutation.set_line_item_custom_fields:type_name -> cart_messages.SetLineItemCustomFields
|
||||||
11, // 15: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails
|
10, // 15: cart_messages.Mutation.add_voucher:type_name -> cart_messages.AddVoucher
|
||||||
16, // [16:16] is the sub-list for method output_type
|
11, // 16: cart_messages.Mutation.remove_voucher:type_name -> cart_messages.RemoveVoucher
|
||||||
16, // [16:16] is the sub-list for method input_type
|
12, // 17: cart_messages.Mutation.upsert_subscription_details:type_name -> cart_messages.UpsertSubscriptionDetails
|
||||||
16, // [16:16] is the sub-list for extension type_name
|
13, // 18: cart_messages.Mutation.set_cart_type:type_name -> cart_messages.SetCartType
|
||||||
16, // [16:16] is the sub-list for extension extendee
|
15, // 19: cart_messages.Mutation.set_recovery_contact:type_name -> cart_messages.SetRecoveryContact
|
||||||
0, // [0:16] is the sub-list for field type_name
|
20, // [20:20] is the sub-list for method output_type
|
||||||
|
20, // [20:20] is the sub-list for method input_type
|
||||||
|
20, // [20:20] is the sub-list for extension type_name
|
||||||
|
20, // [20:20] is the sub-list for extension extendee
|
||||||
|
0, // [0:20] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_cart_proto_init() }
|
func init() { file_cart_proto_init() }
|
||||||
@@ -1381,7 +1555,7 @@ func file_cart_proto_init() {
|
|||||||
}
|
}
|
||||||
file_cart_proto_msgTypes[1].OneofWrappers = []any{}
|
file_cart_proto_msgTypes[1].OneofWrappers = []any{}
|
||||||
file_cart_proto_msgTypes[11].OneofWrappers = []any{}
|
file_cart_proto_msgTypes[11].OneofWrappers = []any{}
|
||||||
file_cart_proto_msgTypes[12].OneofWrappers = []any{
|
file_cart_proto_msgTypes[15].OneofWrappers = []any{
|
||||||
(*Mutation_ClearCart)(nil),
|
(*Mutation_ClearCart)(nil),
|
||||||
(*Mutation_AddItem)(nil),
|
(*Mutation_AddItem)(nil),
|
||||||
(*Mutation_RemoveItem)(nil),
|
(*Mutation_RemoveItem)(nil),
|
||||||
@@ -1394,19 +1568,22 @@ func file_cart_proto_init() {
|
|||||||
(*Mutation_AddVoucher)(nil),
|
(*Mutation_AddVoucher)(nil),
|
||||||
(*Mutation_RemoveVoucher)(nil),
|
(*Mutation_RemoveVoucher)(nil),
|
||||||
(*Mutation_UpsertSubscriptionDetails)(nil),
|
(*Mutation_UpsertSubscriptionDetails)(nil),
|
||||||
|
(*Mutation_SetCartType)(nil),
|
||||||
|
(*Mutation_SetRecoveryContact)(nil),
|
||||||
}
|
}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_proto_rawDesc), len(file_cart_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_cart_proto_rawDesc), len(file_cart_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 1,
|
||||||
NumMessages: 15,
|
NumMessages: 18,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
GoTypes: file_cart_proto_goTypes,
|
GoTypes: file_cart_proto_goTypes,
|
||||||
DependencyIndexes: file_cart_proto_depIdxs,
|
DependencyIndexes: file_cart_proto_depIdxs,
|
||||||
|
EnumInfos: file_cart_proto_enumTypes,
|
||||||
MessageInfos: file_cart_proto_msgTypes,
|
MessageInfos: file_cart_proto_msgTypes,
|
||||||
}.Build()
|
}.Build()
|
||||||
File_cart_proto = out.File
|
File_cart_proto = out.File
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.5
|
// protoc-gen-go v1.36.5
|
||||||
// protoc v7.35.0
|
// protoc v7.35.1
|
||||||
// source: checkout.proto
|
// source: checkout.proto
|
||||||
|
|
||||||
package checkout_messages
|
package checkout_messages
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.5
|
// protoc-gen-go v1.36.5
|
||||||
// protoc v7.35.0
|
// protoc v7.35.1
|
||||||
// source: control_plane.proto
|
// source: control_plane.proto
|
||||||
|
|
||||||
package control_plane_messages
|
package control_plane_messages
|
||||||
@@ -346,11 +346,20 @@ func (x *ClosingNotice) GetHost() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
|
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
|
||||||
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
|
// Receivers arbitrate concurrent cold-cache first-touch claims using
|
||||||
|
// last_changes: the pod whose local grain has the earliest lastChange
|
||||||
|
// wins; ties resolve to the lexicographically smaller hostname.
|
||||||
type OwnershipAnnounce struct {
|
type OwnershipAnnounce struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
|
||||||
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
|
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
|
||||||
|
// last_changes is the per-id UnixNano timestamp of the announcing
|
||||||
|
// pod's local grain's lastChange at the moment of broadcast. -1
|
||||||
|
// means the announcer didn't have a local grain for this id (legacy
|
||||||
|
// path / TakeOwnership without prior load); receivers treat -1 as
|
||||||
|
// "no arbitration done" and accept the remote claim as before. The
|
||||||
|
// slice is aligned 1:1 with ids.
|
||||||
|
LastChanges []int64 `protobuf:"varint,3,rep,packed,name=last_changes,json=lastChanges,proto3" json:"last_changes,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -399,11 +408,21 @@ func (x *OwnershipAnnounce) GetIds() []uint64 {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) GetLastChanges() []int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastChanges
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
||||||
|
// last_changes is carried for wire-shape symmetry with OwnershipAnnounce
|
||||||
|
// and is informational here (expiry is unilateral).
|
||||||
type ExpiryAnnounce struct {
|
type ExpiryAnnounce struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||||
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
|
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
|
||||||
|
LastChanges []int64 `protobuf:"varint,3,rep,packed,name=last_changes,json=lastChanges,proto3" json:"last_changes,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -452,6 +471,13 @@ func (x *ExpiryAnnounce) GetIds() []uint64 {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) GetLastChanges() []int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastChanges
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type ApplyRequest struct {
|
type ApplyRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
@@ -730,91 +756,96 @@ var file_control_plane_proto_rawDesc = string([]byte{
|
|||||||
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23, 0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69,
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23, 0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69,
|
||||||
0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
||||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x22, 0x39, 0x0a, 0x11,
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x22, 0x5c, 0x0a, 0x11,
|
||||||
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63,
|
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63,
|
||||||
0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03,
|
0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03,
|
||||||
0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22, 0x36, 0x0a, 0x0e, 0x45, 0x78, 0x70, 0x69, 0x72,
|
0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x5f,
|
||||||
0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73,
|
0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0b, 0x6c,
|
||||||
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a,
|
0x61, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x22, 0x59, 0x0a, 0x0e, 0x45, 0x78,
|
||||||
0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22,
|
0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04,
|
||||||
0x50, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
||||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x12,
|
0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69,
|
||||||
0x30, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28,
|
0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x67,
|
||||||
0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0b, 0x6c, 0x61, 0x73, 0x74, 0x43, 0x68,
|
||||||
0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
0x61, 0x6e, 0x67, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65,
|
||||||
0x73, 0x22, 0x1c, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x22,
|
0x04, 0x52, 0x02, 0x69, 0x64, 0x12, 0x30, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||||
0x36, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x2a, 0x0a, 0x05, 0x67,
|
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||||
0x72, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f,
|
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x08, 0x6d,
|
||||||
0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79,
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1c, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65,
|
||||||
0x52, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x22, 0x79, 0x0a, 0x0e, 0x4d, 0x75, 0x74, 0x61, 0x74,
|
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70,
|
0x04, 0x52, 0x02, 0x69, 0x64, 0x22, 0x36, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c,
|
||||||
0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2e, 0x0a,
|
0x79, 0x12, 0x2a, 0x0a, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
|
||||||
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
|
|
||||||
0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
|
|
||||||
0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x19, 0x0a,
|
|
||||||
0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05,
|
|
||||||
0x65, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x72, 0x72,
|
|
||||||
0x6f, 0x72, 0x22, 0x7f, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c,
|
|
||||||
0x74, 0x12, 0x2a, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
|
|
||||||
0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||||
0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x44, 0x0a,
|
0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x22, 0x79, 0x0a,
|
||||||
0x09, 0x6d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
|
0x0e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12,
|
||||||
0x32, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
|
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
|
||||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69,
|
0x79, 0x70, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02,
|
||||||
0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x09, 0x6d, 0x75, 0x74, 0x61, 0x74, 0x69,
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||||
0x6f, 0x6e, 0x73, 0x32, 0xd6, 0x05, 0x0a, 0x0c, 0x43, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x50,
|
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73,
|
||||||
0x6c, 0x61, 0x6e, 0x65, 0x12, 0x48, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x1d, 0x2e, 0x63,
|
0x61, 0x67, 0x65, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01,
|
||||||
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x08,
|
||||||
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x21, 0x2e, 0x63, 0x6f,
|
0x0a, 0x06, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x7f, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x6c,
|
||||||
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x2a, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65,
|
||||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x5d,
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||||
0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x12, 0x28, 0x2e, 0x63, 0x6f,
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x73, 0x74,
|
||||||
|
0x61, 0x74, 0x65, 0x12, 0x44, 0x0a, 0x09, 0x6d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73,
|
||||||
|
0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
|
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
|
0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x09,
|
||||||
|
0x6d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0xd6, 0x05, 0x0a, 0x0c, 0x43, 0x6f,
|
||||||
|
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x48, 0x0a, 0x04, 0x50, 0x69,
|
||||||
|
0x6e, 0x67, 0x12, 0x1d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61,
|
||||||
|
0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74,
|
||||||
|
0x79, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e,
|
||||||
|
0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52,
|
||||||
|
0x65, 0x70, 0x6c, 0x79, 0x12, 0x5d, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74,
|
||||||
|
0x65, 0x12, 0x28, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e,
|
||||||
|
0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74,
|
||||||
|
0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f,
|
||||||
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65,
|
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65,
|
||||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f,
|
0x70, 0x6c, 0x79, 0x12, 0x58, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41,
|
||||||
0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e,
|
0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x1d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f,
|
||||||
0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x58, 0x0a,
|
|
||||||
0x10, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64,
|
|
||||||
0x73, 0x12, 0x1d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e,
|
|
||||||
0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
|
|
||||||
0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
|
|
||||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x63, 0x74, 0x6f, 0x72, 0x49,
|
|
||||||
0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x66, 0x0a, 0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75,
|
|
||||||
0x6e, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x12, 0x29, 0x2e, 0x63,
|
|
||||||
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
|
||||||
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41,
|
|
||||||
0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f,
|
|
||||||
0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||||
0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12,
|
0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72,
|
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x66, 0x0a,
|
||||||
0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23,
|
0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68,
|
||||||
0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d,
|
0x69, 0x70, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61,
|
||||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x73,
|
0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65,
|
||||||
0x75, 0x6c, 0x74, 0x12, 0x60, 0x0a, 0x0e, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45,
|
0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e,
|
||||||
0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f,
|
|
||||||
0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45,
|
|
||||||
0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e,
|
|
||||||
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65,
|
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65,
|
||||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e,
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e,
|
||||||
0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x58, 0x0a, 0x07, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67,
|
0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x24,
|
||||||
0x12, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
|
0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d,
|
||||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e,
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71,
|
||||||
0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f,
|
0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
|
||||||
0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70,
|
||||||
0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12,
|
0x70, 0x6c, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x60, 0x0a, 0x0e, 0x41, 0x6e, 0x6e,
|
||||||
0x4b, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f,
|
||||||
|
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75,
|
||||||
|
0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c,
|
||||||
|
0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e,
|
||||||
|
0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x58, 0x0a, 0x07, 0x43,
|
||||||
|
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x63, 0x6f, 0x6e,
|
0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x26, 0x2e,
|
||||||
0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65,
|
||||||
0x67, 0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x42, 0x45, 0x5a, 0x43,
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e,
|
||||||
0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73,
|
0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x4b, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x22, 0x2e, 0x63,
|
||||||
0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70,
|
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
||||||
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x3b, 0x63, 0x6f, 0x6e,
|
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||||
0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
0x1a, 0x20, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
|
||||||
0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70,
|
||||||
|
0x6c, 0x79, 0x42, 0x45, 0x5a, 0x43, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65,
|
||||||
|
0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61,
|
||||||
|
0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72,
|
||||||
|
0x6f, 0x6c, 0x3b, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
|
||||||
|
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||||
|
0x33,
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v7.35.0
|
// - protoc v7.35.1
|
||||||
// source: control_plane.proto
|
// source: control_plane.proto
|
||||||
|
|
||||||
package control_plane_messages
|
package control_plane_messages
|
||||||
|
|||||||
@@ -0,0 +1,933 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.5
|
||||||
|
// protoc v7.35.1
|
||||||
|
// source: control_plane.proto
|
||||||
|
|
||||||
|
package control_plane_messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
anypb "google.golang.org/protobuf/types/known/anypb"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Empty request placeholder (common pattern).
|
||||||
|
type Empty struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Empty) Reset() {
|
||||||
|
*x = Empty{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Empty) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Empty) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Empty) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Empty) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping reply includes responding host and its current unix time (seconds).
|
||||||
|
type PingReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||||
|
UnixTime int64 `protobuf:"varint,2,opt,name=unix_time,json=unixTime,proto3" json:"unix_time,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) Reset() {
|
||||||
|
*x = PingReply{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PingReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *PingReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use PingReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*PingReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PingReply) GetUnixTime() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UnixTime
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// NegotiateRequest carries the caller's full view of known hosts (including self).
|
||||||
|
type NegotiateRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
KnownHosts []string `protobuf:"bytes,1,rep,name=known_hosts,json=knownHosts,proto3" json:"known_hosts,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) Reset() {
|
||||||
|
*x = NegotiateRequest{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*NegotiateRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use NegotiateRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*NegotiateRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateRequest) GetKnownHosts() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.KnownHosts
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NegotiateReply returns the callee's healthy hosts (including itself).
|
||||||
|
type NegotiateReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Hosts []string `protobuf:"bytes,1,rep,name=hosts,proto3" json:"hosts,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) Reset() {
|
||||||
|
*x = NegotiateReply{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*NegotiateReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use NegotiateReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*NegotiateReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *NegotiateReply) GetHosts() []string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Hosts
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CartIdsReply returns the list of cart IDs (string form) currently owned locally.
|
||||||
|
type ActorIdsReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Ids []uint64 `protobuf:"varint,1,rep,packed,name=ids,proto3" json:"ids,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActorIdsReply) Reset() {
|
||||||
|
*x = ActorIdsReply{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActorIdsReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ActorIdsReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ActorIdsReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ActorIdsReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ActorIdsReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ActorIdsReply) GetIds() []uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ids
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerChangeAck retained as response type for Closing RPC (ConfirmOwner removed).
|
||||||
|
type OwnerChangeAck struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"`
|
||||||
|
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) Reset() {
|
||||||
|
*x = OwnerChangeAck{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[5]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*OwnerChangeAck) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[5]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use OwnerChangeAck.ProtoReflect.Descriptor instead.
|
||||||
|
func (*OwnerChangeAck) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{5}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) GetAccepted() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Accepted
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnerChangeAck) GetMessage() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Message
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosingNotice notifies peers this host is terminating (so they can drop / re-resolve).
|
||||||
|
type ClosingNotice struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) Reset() {
|
||||||
|
*x = ClosingNotice{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[6]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ClosingNotice) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[6]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ClosingNotice.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ClosingNotice) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{6}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ClosingNotice) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
|
||||||
|
// Receivers arbitrate concurrent cold-cache first-touch claims using
|
||||||
|
// last_changes: the pod whose local grain has the earliest lastChange
|
||||||
|
// wins; ties resolve to the lexicographically smaller hostname.
|
||||||
|
type OwnershipAnnounce struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` // announcing host
|
||||||
|
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"` // newly claimed cart ids
|
||||||
|
// last_changes is the per-id UnixNano timestamp of the announcing
|
||||||
|
// pod's local grain's lastChange at the moment of broadcast. -1
|
||||||
|
// means the announcer didn't have a local grain for this id (legacy
|
||||||
|
// path / TakeOwnership without prior load); receivers treat -1 as
|
||||||
|
// "no arbitration done" and accept the remote claim as before. The
|
||||||
|
// slice is aligned 1:1 with ids.
|
||||||
|
LastChanges []int64 `protobuf:"varint,3,rep,packed,name=last_changes,json=lastChanges,proto3" json:"last_changes,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) Reset() {
|
||||||
|
*x = OwnershipAnnounce{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[7]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*OwnershipAnnounce) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[7]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use OwnershipAnnounce.ProtoReflect.Descriptor instead.
|
||||||
|
func (*OwnershipAnnounce) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{7}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) GetIds() []uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ids
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *OwnershipAnnounce) GetLastChanges() []int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastChanges
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
||||||
|
// last_changes is carried for wire-shape symmetry with OwnershipAnnounce
|
||||||
|
// and is informational here (expiry is unilateral).
|
||||||
|
type ExpiryAnnounce struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||||
|
Ids []uint64 `protobuf:"varint,2,rep,packed,name=ids,proto3" json:"ids,omitempty"`
|
||||||
|
LastChanges []int64 `protobuf:"varint,3,rep,packed,name=last_changes,json=lastChanges,proto3" json:"last_changes,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) Reset() {
|
||||||
|
*x = ExpiryAnnounce{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[8]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ExpiryAnnounce) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[8]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ExpiryAnnounce.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ExpiryAnnounce) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) GetHost() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Host
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) GetIds() []uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ids
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ExpiryAnnounce) GetLastChanges() []int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.LastChanges
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplyRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
Messages []*anypb.Any `protobuf:"bytes,2,rep,name=messages,proto3" json:"messages,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyRequest) Reset() {
|
||||||
|
*x = ApplyRequest{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[9]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ApplyRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ApplyRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[9]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ApplyRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ApplyRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{9}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyRequest) GetId() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyRequest) GetMessages() []*anypb.Any {
|
||||||
|
if x != nil {
|
||||||
|
return x.Messages
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetRequest struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRequest) Reset() {
|
||||||
|
*x = GetRequest{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[10]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRequest) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetRequest) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[10]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetRequest.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetRequest) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{10}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetRequest) GetId() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Id
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetReply struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Grain *anypb.Any `protobuf:"bytes,1,opt,name=grain,proto3" json:"grain,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetReply) Reset() {
|
||||||
|
*x = GetReply{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[11]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetReply) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*GetReply) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *GetReply) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[11]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use GetReply.ProtoReflect.Descriptor instead.
|
||||||
|
func (*GetReply) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{11}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GetReply) GetGrain() *anypb.Any {
|
||||||
|
if x != nil {
|
||||||
|
return x.Grain
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MutationResult struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
|
||||||
|
Message *anypb.Any `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
|
||||||
|
Error *string `protobuf:"bytes,3,opt,name=error,proto3,oneof" json:"error,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MutationResult) Reset() {
|
||||||
|
*x = MutationResult{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[12]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MutationResult) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MutationResult) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MutationResult) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[12]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MutationResult.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MutationResult) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{12}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MutationResult) GetType() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Type
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MutationResult) GetMessage() *anypb.Any {
|
||||||
|
if x != nil {
|
||||||
|
return x.Message
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MutationResult) GetError() string {
|
||||||
|
if x != nil && x.Error != nil {
|
||||||
|
return *x.Error
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApplyResult struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
State *anypb.Any `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"`
|
||||||
|
Mutations []*MutationResult `protobuf:"bytes,2,rep,name=mutations,proto3" json:"mutations,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyResult) Reset() {
|
||||||
|
*x = ApplyResult{}
|
||||||
|
mi := &file_control_plane_proto_msgTypes[13]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyResult) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ApplyResult) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ApplyResult) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_control_plane_proto_msgTypes[13]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ApplyResult.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ApplyResult) Descriptor() ([]byte, []int) {
|
||||||
|
return file_control_plane_proto_rawDescGZIP(), []int{13}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyResult) GetState() *anypb.Any {
|
||||||
|
if x != nil {
|
||||||
|
return x.State
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ApplyResult) GetMutations() []*MutationResult {
|
||||||
|
if x != nil {
|
||||||
|
return x.Mutations
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_control_plane_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_control_plane_proto_rawDesc = string([]byte{
|
||||||
|
0x0a, 0x13, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x2e,
|
||||||
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x16, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
|
||||||
|
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x19, 0x67,
|
||||||
|
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61,
|
||||||
|
0x6e, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74,
|
||||||
|
0x79, 0x22, 0x3c, 0x0a, 0x09, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x12,
|
||||||
|
0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f,
|
||||||
|
0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x75, 0x6e, 0x69, 0x78, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
|
||||||
|
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x75, 0x6e, 0x69, 0x78, 0x54, 0x69, 0x6d, 0x65, 0x22,
|
||||||
|
0x33, 0x0a, 0x10, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||||
|
0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x5f, 0x68, 0x6f, 0x73,
|
||||||
|
0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x48,
|
||||||
|
0x6f, 0x73, 0x74, 0x73, 0x22, 0x26, 0x0a, 0x0e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74,
|
||||||
|
0x65, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x18,
|
||||||
|
0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x68, 0x6f, 0x73, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x0d,
|
||||||
|
0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x10, 0x0a,
|
||||||
|
0x03, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x22,
|
||||||
|
0x46, 0x0a, 0x0e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63,
|
||||||
|
0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20,
|
||||||
|
0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x18, 0x0a,
|
||||||
|
0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||||
|
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x23, 0x0a, 0x0d, 0x43, 0x6c, 0x6f, 0x73, 0x69,
|
||||||
|
0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
||||||
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x22, 0x5c, 0x0a, 0x11,
|
||||||
|
0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63,
|
||||||
|
0x65, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
|
0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03,
|
||||||
|
0x28, 0x04, 0x52, 0x03, 0x69, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x5f,
|
||||||
|
0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0b, 0x6c,
|
||||||
|
0x61, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x73, 0x22, 0x59, 0x0a, 0x0e, 0x45, 0x78,
|
||||||
|
0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04,
|
||||||
|
0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
||||||
|
0x12, 0x10, 0x0a, 0x03, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x04, 0x52, 0x03, 0x69,
|
||||||
|
0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x67,
|
||||||
|
0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0b, 0x6c, 0x61, 0x73, 0x74, 0x43, 0x68,
|
||||||
|
0x61, 0x6e, 0x67, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65,
|
||||||
|
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
|
0x04, 0x52, 0x02, 0x69, 0x64, 0x12, 0x30, 0x0a, 0x08, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||||
|
0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
|
||||||
|
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x08, 0x6d,
|
||||||
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x1c, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x52, 0x65,
|
||||||
|
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
|
0x04, 0x52, 0x02, 0x69, 0x64, 0x22, 0x36, 0x0a, 0x08, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70, 0x6c,
|
||||||
|
0x79, 0x12, 0x2a, 0x0a, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
|
||||||
|
0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||||
|
0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x22, 0x79, 0x0a,
|
||||||
|
0x0e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12,
|
||||||
|
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74,
|
||||||
|
0x79, 0x70, 0x65, 0x12, 0x2e, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02,
|
||||||
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
|
||||||
|
0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01,
|
||||||
|
0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x88, 0x01, 0x01, 0x42, 0x08,
|
||||||
|
0x0a, 0x06, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0x7f, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x6c,
|
||||||
|
0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x2a, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65,
|
||||||
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||||
|
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x05, 0x73, 0x74,
|
||||||
|
0x61, 0x74, 0x65, 0x12, 0x44, 0x0a, 0x09, 0x6d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73,
|
||||||
|
0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
|
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
|
0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x09,
|
||||||
|
0x6d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x32, 0xd6, 0x05, 0x0a, 0x0c, 0x43, 0x6f,
|
||||||
|
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x50, 0x6c, 0x61, 0x6e, 0x65, 0x12, 0x48, 0x0a, 0x04, 0x50, 0x69,
|
||||||
|
0x6e, 0x67, 0x12, 0x1d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61,
|
||||||
|
0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x6d, 0x70, 0x74,
|
||||||
|
0x79, 0x1a, 0x21, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e,
|
||||||
|
0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52,
|
||||||
|
0x65, 0x70, 0x6c, 0x79, 0x12, 0x5d, 0x0a, 0x09, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74,
|
||||||
|
0x65, 0x12, 0x28, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e,
|
||||||
|
0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74,
|
||||||
|
0x69, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f,
|
||||||
|
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x73, 0x2e, 0x4e, 0x65, 0x67, 0x6f, 0x74, 0x69, 0x61, 0x74, 0x65, 0x52, 0x65,
|
||||||
|
0x70, 0x6c, 0x79, 0x12, 0x58, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x41,
|
||||||
|
0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x12, 0x1d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f,
|
||||||
|
0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||||
|
0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
|
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
|
0x41, 0x63, 0x74, 0x6f, 0x72, 0x49, 0x64, 0x73, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x66, 0x0a,
|
||||||
|
0x11, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x73, 0x68,
|
||||||
|
0x69, 0x70, 0x12, 0x29, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61,
|
||||||
|
0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65,
|
||||||
|
0x72, 0x73, 0x68, 0x69, 0x70, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e,
|
||||||
|
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e,
|
||||||
|
0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x52, 0x0a, 0x05, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x12, 0x24,
|
||||||
|
0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d,
|
||||||
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70, 0x70, 0x6c, 0x79, 0x52, 0x65, 0x71,
|
||||||
|
0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70,
|
||||||
|
0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x70,
|
||||||
|
0x70, 0x6c, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x60, 0x0a, 0x0e, 0x41, 0x6e, 0x6e,
|
||||||
|
0x6f, 0x75, 0x6e, 0x63, 0x65, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x12, 0x26, 0x2e, 0x63, 0x6f,
|
||||||
|
0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
||||||
|
0x61, 0x67, 0x65, 0x73, 0x2e, 0x45, 0x78, 0x70, 0x69, 0x72, 0x79, 0x41, 0x6e, 0x6e, 0x6f, 0x75,
|
||||||
|
0x6e, 0x63, 0x65, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c,
|
||||||
|
0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e,
|
||||||
|
0x65, 0x72, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x58, 0x0a, 0x07, 0x43,
|
||||||
|
0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c,
|
||||||
|
0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
||||||
|
0x43, 0x6c, 0x6f, 0x73, 0x69, 0x6e, 0x67, 0x4e, 0x6f, 0x74, 0x69, 0x63, 0x65, 0x1a, 0x26, 0x2e,
|
||||||
|
0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x43, 0x68, 0x61, 0x6e,
|
||||||
|
0x67, 0x65, 0x41, 0x63, 0x6b, 0x12, 0x4b, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x22, 0x2e, 0x63,
|
||||||
|
0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
||||||
|
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||||
|
0x1a, 0x20, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
|
||||||
|
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x70,
|
||||||
|
0x6c, 0x79, 0x42, 0x45, 0x5a, 0x43, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65,
|
||||||
|
0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61,
|
||||||
|
0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72,
|
||||||
|
0x6f, 0x6c, 0x3b, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x5f, 0x70, 0x6c, 0x61, 0x6e, 0x65,
|
||||||
|
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||||
|
0x33,
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_control_plane_proto_rawDescOnce sync.Once
|
||||||
|
file_control_plane_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_control_plane_proto_rawDescGZIP() []byte {
|
||||||
|
file_control_plane_proto_rawDescOnce.Do(func() {
|
||||||
|
file_control_plane_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_control_plane_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_control_plane_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
|
||||||
|
var file_control_plane_proto_goTypes = []any{
|
||||||
|
(*Empty)(nil), // 0: control_plane_messages.Empty
|
||||||
|
(*PingReply)(nil), // 1: control_plane_messages.PingReply
|
||||||
|
(*NegotiateRequest)(nil), // 2: control_plane_messages.NegotiateRequest
|
||||||
|
(*NegotiateReply)(nil), // 3: control_plane_messages.NegotiateReply
|
||||||
|
(*ActorIdsReply)(nil), // 4: control_plane_messages.ActorIdsReply
|
||||||
|
(*OwnerChangeAck)(nil), // 5: control_plane_messages.OwnerChangeAck
|
||||||
|
(*ClosingNotice)(nil), // 6: control_plane_messages.ClosingNotice
|
||||||
|
(*OwnershipAnnounce)(nil), // 7: control_plane_messages.OwnershipAnnounce
|
||||||
|
(*ExpiryAnnounce)(nil), // 8: control_plane_messages.ExpiryAnnounce
|
||||||
|
(*ApplyRequest)(nil), // 9: control_plane_messages.ApplyRequest
|
||||||
|
(*GetRequest)(nil), // 10: control_plane_messages.GetRequest
|
||||||
|
(*GetReply)(nil), // 11: control_plane_messages.GetReply
|
||||||
|
(*MutationResult)(nil), // 12: control_plane_messages.MutationResult
|
||||||
|
(*ApplyResult)(nil), // 13: control_plane_messages.ApplyResult
|
||||||
|
(*anypb.Any)(nil), // 14: google.protobuf.Any
|
||||||
|
}
|
||||||
|
var file_control_plane_proto_depIdxs = []int32{
|
||||||
|
14, // 0: control_plane_messages.ApplyRequest.messages:type_name -> google.protobuf.Any
|
||||||
|
14, // 1: control_plane_messages.GetReply.grain:type_name -> google.protobuf.Any
|
||||||
|
14, // 2: control_plane_messages.MutationResult.message:type_name -> google.protobuf.Any
|
||||||
|
14, // 3: control_plane_messages.ApplyResult.state:type_name -> google.protobuf.Any
|
||||||
|
12, // 4: control_plane_messages.ApplyResult.mutations:type_name -> control_plane_messages.MutationResult
|
||||||
|
0, // 5: control_plane_messages.ControlPlane.Ping:input_type -> control_plane_messages.Empty
|
||||||
|
2, // 6: control_plane_messages.ControlPlane.Negotiate:input_type -> control_plane_messages.NegotiateRequest
|
||||||
|
0, // 7: control_plane_messages.ControlPlane.GetLocalActorIds:input_type -> control_plane_messages.Empty
|
||||||
|
7, // 8: control_plane_messages.ControlPlane.AnnounceOwnership:input_type -> control_plane_messages.OwnershipAnnounce
|
||||||
|
9, // 9: control_plane_messages.ControlPlane.Apply:input_type -> control_plane_messages.ApplyRequest
|
||||||
|
8, // 10: control_plane_messages.ControlPlane.AnnounceExpiry:input_type -> control_plane_messages.ExpiryAnnounce
|
||||||
|
6, // 11: control_plane_messages.ControlPlane.Closing:input_type -> control_plane_messages.ClosingNotice
|
||||||
|
10, // 12: control_plane_messages.ControlPlane.Get:input_type -> control_plane_messages.GetRequest
|
||||||
|
1, // 13: control_plane_messages.ControlPlane.Ping:output_type -> control_plane_messages.PingReply
|
||||||
|
3, // 14: control_plane_messages.ControlPlane.Negotiate:output_type -> control_plane_messages.NegotiateReply
|
||||||
|
4, // 15: control_plane_messages.ControlPlane.GetLocalActorIds:output_type -> control_plane_messages.ActorIdsReply
|
||||||
|
5, // 16: control_plane_messages.ControlPlane.AnnounceOwnership:output_type -> control_plane_messages.OwnerChangeAck
|
||||||
|
13, // 17: control_plane_messages.ControlPlane.Apply:output_type -> control_plane_messages.ApplyResult
|
||||||
|
5, // 18: control_plane_messages.ControlPlane.AnnounceExpiry:output_type -> control_plane_messages.OwnerChangeAck
|
||||||
|
5, // 19: control_plane_messages.ControlPlane.Closing:output_type -> control_plane_messages.OwnerChangeAck
|
||||||
|
11, // 20: control_plane_messages.ControlPlane.Get:output_type -> control_plane_messages.GetReply
|
||||||
|
13, // [13:21] is the sub-list for method output_type
|
||||||
|
5, // [5:13] is the sub-list for method input_type
|
||||||
|
5, // [5:5] is the sub-list for extension type_name
|
||||||
|
5, // [5:5] is the sub-list for extension extendee
|
||||||
|
0, // [0:5] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_control_plane_proto_init() }
|
||||||
|
func file_control_plane_proto_init() {
|
||||||
|
if File_control_plane_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_control_plane_proto_msgTypes[12].OneofWrappers = []any{}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_control_plane_proto_rawDesc), len(file_control_plane_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 14,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 1,
|
||||||
|
},
|
||||||
|
GoTypes: file_control_plane_proto_goTypes,
|
||||||
|
DependencyIndexes: file_control_plane_proto_depIdxs,
|
||||||
|
MessageInfos: file_control_plane_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_control_plane_proto = out.File
|
||||||
|
file_control_plane_proto_goTypes = nil
|
||||||
|
file_control_plane_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -55,16 +55,28 @@ message ClosingNotice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
|
// OwnershipAnnounce broadcasts first-touch ownership claims for cart IDs.
|
||||||
// First claim wins; receivers SHOULD NOT overwrite an existing different owner.
|
// Receivers arbitrate concurrent cold-cache first-touch claims using
|
||||||
|
// last_changes: the pod whose local grain has the earliest lastChange
|
||||||
|
// wins; ties resolve to the lexicographically smaller hostname.
|
||||||
message OwnershipAnnounce {
|
message OwnershipAnnounce {
|
||||||
string host = 1; // announcing host
|
string host = 1; // announcing host
|
||||||
repeated uint64 ids = 2; // newly claimed cart ids
|
repeated uint64 ids = 2; // newly claimed cart ids
|
||||||
|
// last_changes is the per-id UnixNano timestamp of the announcing
|
||||||
|
// pod's local grain's lastChange at the moment of broadcast. -1
|
||||||
|
// means the announcer didn't have a local grain for this id (legacy
|
||||||
|
// path / TakeOwnership without prior load); receivers treat -1 as
|
||||||
|
// "no arbitration done" and accept the remote claim as before. The
|
||||||
|
// slice is aligned 1:1 with ids.
|
||||||
|
repeated int64 last_changes = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
// ExpiryAnnounce broadcasts that a host evicted the provided cart IDs.
|
||||||
|
// last_changes is carried for wire-shape symmetry with OwnershipAnnounce
|
||||||
|
// and is informational here (expiry is unilateral).
|
||||||
message ExpiryAnnounce {
|
message ExpiryAnnounce {
|
||||||
string host = 1;
|
string host = 1;
|
||||||
repeated uint64 ids = 2;
|
repeated uint64 ids = 2;
|
||||||
|
repeated int64 last_changes = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ApplyRequest {
|
message ApplyRequest {
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
|
// - protoc v7.35.1
|
||||||
|
// source: control_plane.proto
|
||||||
|
|
||||||
|
package control_plane_messages
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
grpc "google.golang.org/grpc"
|
||||||
|
codes "google.golang.org/grpc/codes"
|
||||||
|
status "google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This is a compile-time assertion to ensure that this generated file
|
||||||
|
// is compatible with the grpc package it is being compiled against.
|
||||||
|
// Requires gRPC-Go v1.64.0 or later.
|
||||||
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
|
const (
|
||||||
|
ControlPlane_Ping_FullMethodName = "/control_plane_messages.ControlPlane/Ping"
|
||||||
|
ControlPlane_Negotiate_FullMethodName = "/control_plane_messages.ControlPlane/Negotiate"
|
||||||
|
ControlPlane_GetLocalActorIds_FullMethodName = "/control_plane_messages.ControlPlane/GetLocalActorIds"
|
||||||
|
ControlPlane_AnnounceOwnership_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceOwnership"
|
||||||
|
ControlPlane_Apply_FullMethodName = "/control_plane_messages.ControlPlane/Apply"
|
||||||
|
ControlPlane_AnnounceExpiry_FullMethodName = "/control_plane_messages.ControlPlane/AnnounceExpiry"
|
||||||
|
ControlPlane_Closing_FullMethodName = "/control_plane_messages.ControlPlane/Closing"
|
||||||
|
ControlPlane_Get_FullMethodName = "/control_plane_messages.ControlPlane/Get"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ControlPlaneClient is the client API for ControlPlane service.
|
||||||
|
//
|
||||||
|
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||||
|
//
|
||||||
|
// ControlPlane defines cluster coordination and ownership operations.
|
||||||
|
type ControlPlaneClient interface {
|
||||||
|
// Ping for liveness; lightweight health signal.
|
||||||
|
Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error)
|
||||||
|
// Negotiate merges host views; used during discovery & convergence.
|
||||||
|
Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error)
|
||||||
|
// GetCartIds lists currently owned cart IDs on this node.
|
||||||
|
GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error)
|
||||||
|
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
|
||||||
|
AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
||||||
|
Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResult, error)
|
||||||
|
// Expiry announcement: drop remote ownership hints when local TTL expires.
|
||||||
|
AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
||||||
|
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||||
|
Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error)
|
||||||
|
Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type controlPlaneClient struct {
|
||||||
|
cc grpc.ClientConnInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewControlPlaneClient(cc grpc.ClientConnInterface) ControlPlaneClient {
|
||||||
|
return &controlPlaneClient{cc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*PingReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(PingReply)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_Ping_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) Negotiate(ctx context.Context, in *NegotiateRequest, opts ...grpc.CallOption) (*NegotiateReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(NegotiateReply)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_Negotiate_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) GetLocalActorIds(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*ActorIdsReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ActorIdsReply)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_GetLocalActorIds_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) AnnounceOwnership(ctx context.Context, in *OwnershipAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(OwnerChangeAck)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_AnnounceOwnership_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) Apply(ctx context.Context, in *ApplyRequest, opts ...grpc.CallOption) (*ApplyResult, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ApplyResult)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_Apply_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) AnnounceExpiry(ctx context.Context, in *ExpiryAnnounce, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(OwnerChangeAck)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_AnnounceExpiry_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) Closing(ctx context.Context, in *ClosingNotice, opts ...grpc.CallOption) (*OwnerChangeAck, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(OwnerChangeAck)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_Closing_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *controlPlaneClient) Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetReply, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetReply)
|
||||||
|
err := c.cc.Invoke(ctx, ControlPlane_Get_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlaneServer is the server API for ControlPlane service.
|
||||||
|
// All implementations must embed UnimplementedControlPlaneServer
|
||||||
|
// for forward compatibility.
|
||||||
|
//
|
||||||
|
// ControlPlane defines cluster coordination and ownership operations.
|
||||||
|
type ControlPlaneServer interface {
|
||||||
|
// Ping for liveness; lightweight health signal.
|
||||||
|
Ping(context.Context, *Empty) (*PingReply, error)
|
||||||
|
// Negotiate merges host views; used during discovery & convergence.
|
||||||
|
Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error)
|
||||||
|
// GetCartIds lists currently owned cart IDs on this node.
|
||||||
|
GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error)
|
||||||
|
// Ownership announcement: first-touch claim broadcast (idempotent; best-effort).
|
||||||
|
AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error)
|
||||||
|
Apply(context.Context, *ApplyRequest) (*ApplyResult, error)
|
||||||
|
// Expiry announcement: drop remote ownership hints when local TTL expires.
|
||||||
|
AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error)
|
||||||
|
// Closing announces graceful shutdown so peers can proactively adjust.
|
||||||
|
Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error)
|
||||||
|
Get(context.Context, *GetRequest) (*GetReply, error)
|
||||||
|
mustEmbedUnimplementedControlPlaneServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnimplementedControlPlaneServer must be embedded to have
|
||||||
|
// forward compatible implementations.
|
||||||
|
//
|
||||||
|
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||||
|
// pointer dereference when methods are called.
|
||||||
|
type UnimplementedControlPlaneServer struct{}
|
||||||
|
|
||||||
|
func (UnimplementedControlPlaneServer) Ping(context.Context, *Empty) (*PingReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) Negotiate(context.Context, *NegotiateRequest) (*NegotiateReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method Negotiate not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) GetLocalActorIds(context.Context, *Empty) (*ActorIdsReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetLocalActorIds not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) AnnounceOwnership(context.Context, *OwnershipAnnounce) (*OwnerChangeAck, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method AnnounceOwnership not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) Apply(context.Context, *ApplyRequest) (*ApplyResult, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method Apply not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) AnnounceExpiry(context.Context, *ExpiryAnnounce) (*OwnerChangeAck, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method AnnounceExpiry not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) Closing(context.Context, *ClosingNotice) (*OwnerChangeAck, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method Closing not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) Get(context.Context, *GetRequest) (*GetReply, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method Get not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedControlPlaneServer) mustEmbedUnimplementedControlPlaneServer() {}
|
||||||
|
func (UnimplementedControlPlaneServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
|
// UnsafeControlPlaneServer may be embedded to opt out of forward compatibility for this service.
|
||||||
|
// Use of this interface is not recommended, as added methods to ControlPlaneServer will
|
||||||
|
// result in compilation errors.
|
||||||
|
type UnsafeControlPlaneServer interface {
|
||||||
|
mustEmbedUnimplementedControlPlaneServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterControlPlaneServer(s grpc.ServiceRegistrar, srv ControlPlaneServer) {
|
||||||
|
// If the following call pancis, it indicates UnimplementedControlPlaneServer was
|
||||||
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||||
|
t.testEmbeddedByValue()
|
||||||
|
}
|
||||||
|
s.RegisterService(&ControlPlane_ServiceDesc, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(Empty)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).Ping(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_Ping_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).Ping(ctx, req.(*Empty))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_Negotiate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(NegotiateRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).Negotiate(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_Negotiate_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).Negotiate(ctx, req.(*NegotiateRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_GetLocalActorIds_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(Empty)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_GetLocalActorIds_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).GetLocalActorIds(ctx, req.(*Empty))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_AnnounceOwnership_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(OwnershipAnnounce)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_AnnounceOwnership_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).AnnounceOwnership(ctx, req.(*OwnershipAnnounce))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_Apply_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ApplyRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).Apply(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_Apply_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).Apply(ctx, req.(*ApplyRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_AnnounceExpiry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ExpiryAnnounce)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_AnnounceExpiry_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).AnnounceExpiry(ctx, req.(*ExpiryAnnounce))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_Closing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ClosingNotice)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).Closing(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_Closing_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).Closing(ctx, req.(*ClosingNotice))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _ControlPlane_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(ControlPlaneServer).Get(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: ControlPlane_Get_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(ControlPlaneServer).Get(ctx, req.(*GetRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlPlane_ServiceDesc is the grpc.ServiceDesc for ControlPlane service.
|
||||||
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
|
// and not to be introspected or modified (even as a copy)
|
||||||
|
var ControlPlane_ServiceDesc = grpc.ServiceDesc{
|
||||||
|
ServiceName: "control_plane_messages.ControlPlane",
|
||||||
|
HandlerType: (*ControlPlaneServer)(nil),
|
||||||
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "Ping",
|
||||||
|
Handler: _ControlPlane_Ping_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Negotiate",
|
||||||
|
Handler: _ControlPlane_Negotiate_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetLocalActorIds",
|
||||||
|
Handler: _ControlPlane_GetLocalActorIds_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AnnounceOwnership",
|
||||||
|
Handler: _ControlPlane_AnnounceOwnership_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Apply",
|
||||||
|
Handler: _ControlPlane_Apply_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "AnnounceExpiry",
|
||||||
|
Handler: _ControlPlane_AnnounceExpiry_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Closing",
|
||||||
|
Handler: _ControlPlane_Closing_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "Get",
|
||||||
|
Handler: _ControlPlane_Get_Handler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Streams: []grpc.StreamDesc{},
|
||||||
|
Metadata: "control_plane.proto",
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ message OrderLine {
|
|||||||
int64 total_amount = 7;
|
int64 total_amount = 7;
|
||||||
int64 total_tax = 8;
|
int64 total_tax = 8;
|
||||||
string location = 9; // inventory location / store id for commit; empty = order country
|
string location = 9; // inventory location / store id for commit; empty = order country
|
||||||
|
bool drop_ship = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlaceOrder creates the order (only legal when the order does not yet exist).
|
// PlaceOrder creates the order (only legal when the order does not yet exist).
|
||||||
|
|||||||
+116
-114
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.5
|
// protoc-gen-go v1.36.5
|
||||||
// protoc v7.35.0
|
// protoc v7.35.1
|
||||||
// source: order.proto
|
// source: order.proto
|
||||||
|
|
||||||
package order_messages
|
package order_messages
|
||||||
@@ -955,7 +955,7 @@ var File_order_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
var file_order_proto_rawDesc = string([]byte{
|
var file_order_proto_rawDesc = string([]byte{
|
||||||
0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x6f,
|
0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x6f,
|
||||||
0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x81, 0x02,
|
0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x22, 0x9e, 0x02,
|
||||||
0x0a, 0x09, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72,
|
0x0a, 0x09, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72,
|
||||||
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
||||||
0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75,
|
0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x73, 0x6b, 0x75,
|
||||||
@@ -972,122 +972,124 @@ var file_order_proto_rawDesc = string([]byte{
|
|||||||
0x6c, 0x5f, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74,
|
0x6c, 0x5f, 0x74, 0x61, 0x78, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74,
|
||||||
0x61, 0x6c, 0x54, 0x61, 0x78, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
|
0x61, 0x6c, 0x54, 0x61, 0x78, 0x12, 0x1a, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
|
||||||
0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
|
0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
|
||||||
0x6e, 0x22, 0xcf, 0x03, 0x0a, 0x0a, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72,
|
0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x73, 0x68, 0x69, 0x70, 0x18, 0x0a,
|
||||||
0x12, 0x27, 0x0a, 0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65,
|
0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x64, 0x72, 0x6f, 0x70, 0x53, 0x68, 0x69, 0x70, 0x22, 0xcf,
|
||||||
0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
0x03, 0x0a, 0x0a, 0x50, 0x6c, 0x61, 0x63, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x27, 0x0a,
|
||||||
0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x72,
|
0x0f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65,
|
||||||
0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74,
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66,
|
||||||
0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x03,
|
0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x63, 0x61, 0x72, 0x74, 0x5f, 0x69,
|
||||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x16,
|
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12,
|
||||||
0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
|
0x1a, 0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||||
0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
|
0x09, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x6c,
|
||||||
0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79,
|
0x6f, 0x63, 0x61, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6c, 0x6f, 0x63,
|
||||||
0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74,
|
0x61, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x05,
|
||||||
0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x41, 0x6d, 0x6f,
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x21, 0x0a,
|
||||||
0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x61, 0x78,
|
0x0c, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20,
|
||||||
0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x61, 0x78,
|
0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74,
|
||||||
0x12, 0x2f, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x74, 0x61, 0x78, 0x18, 0x07, 0x20,
|
||||||
0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
0x01, 0x28, 0x03, 0x52, 0x08, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x54, 0x61, 0x78, 0x12, 0x2f, 0x0a,
|
||||||
0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65,
|
0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f,
|
||||||
0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x65, 0x6d,
|
0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72,
|
||||||
0x61, 0x69, 0x6c, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f,
|
0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x25,
|
||||||
0x6d, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74,
|
0x0a, 0x0e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c,
|
||||||
0x6f, 0x6d, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52,
|
0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72,
|
||||||
0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a,
|
0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x65,
|
||||||
0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x75,
|
||||||
0x18, 0x0b, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x41,
|
0x73, 0x74, 0x6f, 0x6d, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69,
|
||||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69,
|
0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0b, 0x20,
|
||||||
0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c,
|
0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72,
|
||||||
0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73,
|
0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f,
|
||||||
0x73, 0x12, 0x20, 0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d,
|
0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f, 0x73,
|
||||||
0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x41,
|
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x20,
|
||||||
0x74, 0x4d, 0x73, 0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65,
|
0x0a, 0x0c, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0d,
|
||||||
0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69,
|
0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x70, 0x6c, 0x61, 0x63, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73,
|
||||||
0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69,
|
0x22, 0x79, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x50, 0x61, 0x79,
|
||||||
0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20,
|
0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
||||||
0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72,
|
|
||||||
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
|
||||||
0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f,
|
|
||||||
0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x77,
|
|
||||||
0x0a, 0x0e, 0x43, 0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74,
|
|
||||||
0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01,
|
|
||||||
0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06,
|
|
||||||
0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d,
|
|
||||||
0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63,
|
|
||||||
0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
|
||||||
0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28,
|
|
||||||
0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x4b, 0x0a, 0x0f, 0x46, 0x75, 0x6c, 0x66, 0x69,
|
|
||||||
0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65,
|
|
||||||
0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72,
|
|
||||||
0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
|
||||||
0x74, 0x69, 0x74, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e,
|
|
||||||
0x74, 0x69, 0x74, 0x79, 0x22, 0xd5, 0x01, 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x46,
|
|
||||||
0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
|
|
||||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x61,
|
|
||||||
0x72, 0x72, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x61, 0x72,
|
|
||||||
0x72, 0x69, 0x65, 0x72, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67,
|
|
||||||
0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74,
|
|
||||||
0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x21, 0x0a,
|
|
||||||
0x0c, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20,
|
|
||||||
0x01, 0x28, 0x09, 0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x55, 0x72, 0x69,
|
|
||||||
0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
|
||||||
0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
|
||||||
0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65,
|
|
||||||
0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73,
|
|
||||||
0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x24, 0x0a, 0x0d,
|
|
||||||
0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x13, 0x0a,
|
|
||||||
0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
|
|
||||||
0x4d, 0x73, 0x22, 0x3a, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x72, 0x64, 0x65,
|
|
||||||
0x72, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
|
|
||||||
0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f,
|
|
||||||
0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x83,
|
|
||||||
0x01, 0x0a, 0x0d, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x74, 0x75, 0x72, 0x6e,
|
|
||||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
|
|
||||||
0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
|
||||||
0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65,
|
|
||||||
0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f,
|
|
||||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c,
|
|
||||||
0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12,
|
|
||||||
0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04,
|
|
||||||
0x61, 0x74, 0x4d, 0x73, 0x22, 0x91, 0x01, 0x0a, 0x0b, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65,
|
|
||||||
0x66, 0x75, 0x6e, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
|
||||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72,
|
||||||
0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
|
0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
|
||||||
0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65,
|
0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65,
|
||||||
0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66,
|
0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66,
|
||||||
0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e,
|
0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
|
||||||
0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72,
|
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x77, 0x0a, 0x0e, 0x43,
|
||||||
0x6e, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01,
|
0x61, 0x70, 0x74, 0x75, 0x72, 0x65, 0x50, 0x61, 0x79, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a,
|
||||||
0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a, 0x0f, 0x52, 0x65, 0x71,
|
0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||||
0x75, 0x65, 0x73, 0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x0e, 0x0a, 0x02,
|
0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f,
|
||||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09,
|
0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e,
|
||||||
0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x03,
|
||||||
0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61,
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12,
|
||||||
0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f,
|
0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04,
|
||||||
0x6e, 0x12, 0x42, 0x0a, 0x0c, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x6c, 0x69, 0x6e, 0x65,
|
0x61, 0x74, 0x4d, 0x73, 0x22, 0x4b, 0x0a, 0x0f, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d,
|
||||||
0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f,
|
0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72,
|
||||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c,
|
0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65,
|
||||||
0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x0b, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e,
|
0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74,
|
||||||
0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x36, 0x0a, 0x09, 0x6e, 0x65, 0x77, 0x5f, 0x6c, 0x69, 0x6e,
|
0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74,
|
||||||
0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72,
|
0x79, 0x22, 0xd5, 0x01, 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x46, 0x75, 0x6c, 0x66,
|
||||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c,
|
0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||||
0x69, 0x6e, 0x65, 0x52, 0x08, 0x6e, 0x65, 0x77, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a,
|
0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x61, 0x72, 0x72, 0x69,
|
||||||
0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74,
|
0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x61, 0x72, 0x72, 0x69, 0x65,
|
||||||
0x4d, 0x73, 0x22, 0xa2, 0x01, 0x0a, 0x10, 0x45, 0x64, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72,
|
0x72, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x6e, 0x75,
|
||||||
0x44, 0x65, 0x74, 0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70,
|
0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x74, 0x72, 0x61, 0x63,
|
||||||
0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28,
|
0x6b, 0x69, 0x6e, 0x67, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x72,
|
||||||
0x0c, 0x52, 0x0f, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65,
|
0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
|
||||||
0x73, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64,
|
0x52, 0x0b, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x55, 0x72, 0x69, 0x12, 0x35, 0x0a,
|
||||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c,
|
0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f,
|
||||||
0x6c, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73,
|
0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75,
|
||||||
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20,
|
0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e, 0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c,
|
||||||
0x01, 0x28, 0x03, 0x52, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x69,
|
0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20,
|
||||||
0x63, 0x65, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28,
|
0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x24, 0x0a, 0x0d, 0x43, 0x6f, 0x6d,
|
||||||
0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x2e, 0x6b,
|
0x70, 0x6c, 0x65, 0x74, 0x65, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74,
|
||||||
0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63,
|
0x5f, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22,
|
||||||
0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
|
0x3a, 0x0a, 0x0b, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x16,
|
||||||
0x6f, 0x72, 0x64, 0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73,
|
0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
|
||||||
0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
|
||||||
|
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x0d,
|
||||||
|
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x12, 0x0e, 0x0a,
|
||||||
|
0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x16, 0x0a,
|
||||||
|
0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72,
|
||||||
|
0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x03,
|
||||||
|
0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73,
|
||||||
|
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e,
|
||||||
|
0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x05, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05,
|
||||||
|
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d,
|
||||||
|
0x73, 0x22, 0x91, 0x01, 0x0a, 0x0b, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x75, 0x6e,
|
||||||
|
0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20,
|
||||||
|
0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a,
|
||||||
|
0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x61,
|
||||||
|
0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e,
|
||||||
|
0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65,
|
||||||
|
0x6e, 0x63, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64,
|
||||||
|
0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x64,
|
||||||
|
0x12, 0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52,
|
||||||
|
0x04, 0x61, 0x74, 0x4d, 0x73, 0x22, 0xe7, 0x01, 0x0a, 0x0f, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||||
|
0x74, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
||||||
|
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x65, 0x74,
|
||||||
|
0x75, 0x72, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65,
|
||||||
|
0x74, 0x75, 0x72, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
|
||||||
|
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x42,
|
||||||
|
0x0a, 0x0c, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18, 0x04,
|
||||||
|
0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73,
|
||||||
|
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x46, 0x75, 0x6c, 0x66, 0x69, 0x6c, 0x6c, 0x6d, 0x65, 0x6e,
|
||||||
|
0x74, 0x4c, 0x69, 0x6e, 0x65, 0x52, 0x0b, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x4c, 0x69, 0x6e,
|
||||||
|
0x65, 0x73, 0x12, 0x36, 0x0a, 0x09, 0x6e, 0x65, 0x77, 0x5f, 0x6c, 0x69, 0x6e, 0x65, 0x73, 0x18,
|
||||||
|
0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65,
|
||||||
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x4c, 0x69, 0x6e, 0x65,
|
||||||
|
0x52, 0x08, 0x6e, 0x65, 0x77, 0x4c, 0x69, 0x6e, 0x65, 0x73, 0x12, 0x13, 0x0a, 0x05, 0x61, 0x74,
|
||||||
|
0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04, 0x61, 0x74, 0x4d, 0x73, 0x22,
|
||||||
|
0xa2, 0x01, 0x0a, 0x10, 0x45, 0x64, 0x69, 0x74, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x44, 0x65, 0x74,
|
||||||
|
0x61, 0x69, 0x6c, 0x73, 0x12, 0x29, 0x0a, 0x10, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67,
|
||||||
|
0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0f,
|
||||||
|
0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12,
|
||||||
|
0x27, 0x0a, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65,
|
||||||
|
0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x0e, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e,
|
||||||
|
0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x68, 0x69, 0x70,
|
||||||
|
0x70, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03,
|
||||||
|
0x52, 0x0d, 0x73, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x69, 0x63, 0x65, 0x12,
|
||||||
|
0x13, 0x0a, 0x05, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x04,
|
||||||
|
0x61, 0x74, 0x4d, 0x73, 0x42, 0x3b, 0x5a, 0x39, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e,
|
||||||
|
0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74,
|
||||||
|
0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6f, 0x72, 0x64,
|
||||||
|
0x65, 0x72, 0x3b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||||
|
0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ message SetProfile {
|
|||||||
optional string language = 4;
|
optional string language = 4;
|
||||||
optional string currency = 5;
|
optional string currency = 5;
|
||||||
optional string avatarUrl = 6;
|
optional string avatarUrl = 6;
|
||||||
|
optional bool orderEmails = 7;
|
||||||
|
optional bool marketingEmails = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAddress adds a new address to the user's address book.
|
// AddAddress adds a new address to the user's address book.
|
||||||
|
|||||||
+122
-114
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.5
|
// protoc-gen-go v1.36.5
|
||||||
// protoc v7.35.0
|
// protoc v7.35.1
|
||||||
// source: profile.proto
|
// source: profile.proto
|
||||||
|
|
||||||
package profile_messages
|
package profile_messages
|
||||||
@@ -156,17 +156,17 @@ func (x *Address) GetIsDefaultBilling() bool {
|
|||||||
|
|
||||||
// SetProfile sets the user's core profile information.
|
// SetProfile sets the user's core profile information.
|
||||||
type SetProfile struct {
|
type SetProfile struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"`
|
Name *string `protobuf:"bytes,1,opt,name=name,proto3,oneof" json:"name,omitempty"`
|
||||||
Email *string `protobuf:"bytes,2,opt,name=email,proto3,oneof" json:"email,omitempty"`
|
Email *string `protobuf:"bytes,2,opt,name=email,proto3,oneof" json:"email,omitempty"`
|
||||||
Phone *string `protobuf:"bytes,3,opt,name=phone,proto3,oneof" json:"phone,omitempty"`
|
Phone *string `protobuf:"bytes,3,opt,name=phone,proto3,oneof" json:"phone,omitempty"`
|
||||||
Language *string `protobuf:"bytes,4,opt,name=language,proto3,oneof" json:"language,omitempty"`
|
Language *string `protobuf:"bytes,4,opt,name=language,proto3,oneof" json:"language,omitempty"`
|
||||||
Currency *string `protobuf:"bytes,5,opt,name=currency,proto3,oneof" json:"currency,omitempty"`
|
Currency *string `protobuf:"bytes,5,opt,name=currency,proto3,oneof" json:"currency,omitempty"`
|
||||||
AvatarUrl *string `protobuf:"bytes,6,opt,name=avatarUrl,proto3,oneof" json:"avatarUrl,omitempty"`
|
AvatarUrl *string `protobuf:"bytes,6,opt,name=avatarUrl,proto3,oneof" json:"avatarUrl,omitempty"`
|
||||||
OrderEmails *bool `protobuf:"varint,7,opt,name=orderEmails,proto3,oneof" json:"orderEmails,omitempty"`
|
OrderEmails *bool `protobuf:"varint,7,opt,name=orderEmails,proto3,oneof" json:"orderEmails,omitempty"`
|
||||||
MarketingEmails *bool `protobuf:"varint,8,opt,name=marketingEmails,proto3,oneof" json:"marketingEmails,omitempty"`
|
MarketingEmails *bool `protobuf:"varint,8,opt,name=marketingEmails,proto3,oneof" json:"marketingEmails,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *SetProfile) Reset() {
|
func (x *SetProfile) Reset() {
|
||||||
@@ -889,7 +889,7 @@ var file_profile_proto_rawDesc = string([]byte{
|
|||||||
0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
0x0c, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
||||||
0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72,
|
0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72,
|
||||||
0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
|
0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
|
||||||
0x6e, 0x65, 0x22, 0x85, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c,
|
0x6e, 0x65, 0x22, 0xff, 0x02, 0x0a, 0x0a, 0x53, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c,
|
||||||
0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48,
|
0x65, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||||
0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d,
|
0x00, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x65, 0x6d,
|
||||||
0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x65, 0x6d, 0x61,
|
0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x05, 0x65, 0x6d, 0x61,
|
||||||
@@ -901,108 +901,116 @@ var file_profile_proto_rawDesc = string([]byte{
|
|||||||
0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x88,
|
0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x88,
|
||||||
0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x18,
|
0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x18,
|
||||||
0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55,
|
0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x09, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55,
|
||||||
0x72, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08,
|
0x72, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0b, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x45, 0x6d,
|
||||||
0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f,
|
0x61, 0x69, 0x6c, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x48, 0x06, 0x52, 0x0b, 0x6f, 0x72,
|
||||||
0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x42,
|
0x64, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x12, 0x2d, 0x0a, 0x0f,
|
||||||
0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a,
|
0x6d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x69, 0x6e, 0x67, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x73, 0x18,
|
||||||
0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c, 0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64,
|
0x08, 0x20, 0x01, 0x28, 0x08, 0x48, 0x07, 0x52, 0x0f, 0x6d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x69,
|
||||||
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x33, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72,
|
0x6e, 0x67, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x73, 0x88, 0x01, 0x01, 0x42, 0x07, 0x0a, 0x05, 0x5f,
|
||||||
0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66,
|
0x6e, 0x61, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x08,
|
||||||
0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64,
|
0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6c, 0x61, 0x6e,
|
||||||
0x72, 0x65, 0x73, 0x73, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0xab, 0x04,
|
0x67, 0x75, 0x61, 0x67, 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
|
||||||
0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12,
|
0x63, 0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x55, 0x72, 0x6c,
|
||||||
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12,
|
0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x73,
|
||||||
0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00,
|
0x42, 0x12, 0x0a, 0x10, 0x5f, 0x6d, 0x61, 0x72, 0x6b, 0x65, 0x74, 0x69, 0x6e, 0x67, 0x45, 0x6d,
|
||||||
0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75,
|
0x61, 0x69, 0x6c, 0x73, 0x22, 0x41, 0x0a, 0x0a, 0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65,
|
||||||
0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x08,
|
0x73, 0x73, 0x12, 0x33, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20,
|
||||||
0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61,
|
0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65,
|
||||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28,
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x07,
|
||||||
0x09, 0x48, 0x02, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65,
|
0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0xab, 0x04, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61,
|
||||||
0x31, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c,
|
0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18,
|
||||||
0x69, 0x6e, 0x65, 0x32, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x0c, 0x61, 0x64,
|
0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, 0x05, 0x6c, 0x61, 0x62,
|
||||||
0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a,
|
0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65,
|
||||||
0x04, 0x63, 0x69, 0x74, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x04, 0x63,
|
0x6c, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65,
|
||||||
0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18,
|
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x08, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61,
|
||||||
0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x88, 0x01,
|
0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x27, 0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||||
0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06,
|
0x4c, 0x69, 0x6e, 0x65, 0x31, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02, 0x52, 0x0c, 0x61,
|
||||||
0x52, 0x03, 0x7a, 0x69, 0x70, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e,
|
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x88, 0x01, 0x01, 0x12, 0x27,
|
||||||
0x74, 0x72, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, 0x07, 0x52, 0x07, 0x63, 0x6f, 0x75,
|
0x0a, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x18, 0x05,
|
||||||
0x6e, 0x74, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x0c, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c,
|
||||||
0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88,
|
0x69, 0x6e, 0x65, 0x32, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x63, 0x69, 0x74, 0x79, 0x18,
|
||||||
0x01, 0x01, 0x12, 0x31, 0x0a, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53,
|
0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x04, 0x63, 0x69, 0x74, 0x79, 0x88, 0x01, 0x01,
|
||||||
0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x48, 0x09, 0x52,
|
0x12, 0x19, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48,
|
||||||
0x11, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69,
|
0x05, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x7a,
|
||||||
0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x2f, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75,
|
0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x03, 0x7a, 0x69, 0x70, 0x88,
|
||||||
0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48,
|
0x01, 0x01, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x18, 0x09, 0x20,
|
||||||
0x0a, 0x52, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c,
|
0x01, 0x28, 0x09, 0x48, 0x07, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x88, 0x01,
|
||||||
0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c,
|
0x01, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09,
|
||||||
0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66, 0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a,
|
0x48, 0x08, 0x52, 0x05, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x11,
|
||||||
0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f,
|
0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e,
|
||||||
0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42,
|
0x67, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x48, 0x09, 0x52, 0x11, 0x69, 0x73, 0x44, 0x65, 0x66,
|
||||||
0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69, 0x74, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61,
|
0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12,
|
||||||
0x74, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63,
|
0x2f, 0x0a, 0x10, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c,
|
||||||
0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65,
|
0x69, 0x6e, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x10, 0x69, 0x73, 0x44,
|
||||||
0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68,
|
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01,
|
||||||
0x69, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66,
|
0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x66,
|
||||||
0x61, 0x75, 0x6c, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52,
|
0x75, 0x6c, 0x6c, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64, 0x72,
|
||||||
0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02,
|
0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x31, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x61, 0x64, 0x64,
|
||||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0x38, 0x0a, 0x08,
|
0x72, 0x65, 0x73, 0x73, 0x4c, 0x69, 0x6e, 0x65, 0x32, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x63, 0x69,
|
||||||
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74,
|
0x74, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x42, 0x06, 0x0a, 0x04,
|
||||||
0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
|
0x5f, 0x7a, 0x69, 0x70, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79,
|
||||||
0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
0x42, 0x08, 0x0a, 0x06, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x69,
|
||||||
0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x22, 0x46, 0x0a, 0x0c, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68,
|
0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x53, 0x68, 0x69, 0x70, 0x70, 0x69, 0x6e, 0x67,
|
||||||
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f,
|
0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x73, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x42, 0x69,
|
||||||
0x75, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x63, 0x68, 0x65, 0x63,
|
0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x22, 0x1f, 0x0a, 0x0d, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41,
|
||||||
0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64,
|
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x22, 0x63,
|
0x28, 0x0d, 0x52, 0x02, 0x69, 0x64, 0x22, 0x38, 0x0a, 0x08, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61,
|
||||||
0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f,
|
0x72, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||||
0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20,
|
0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61,
|
||||||
0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65,
|
0x62, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c,
|
||||||
0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20,
|
0x22, 0x46, 0x0a, 0x0c, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74,
|
||||||
0x01, 0x28, 0x04, 0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73,
|
0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64, 0x18, 0x01,
|
||||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61,
|
0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x49, 0x64,
|
||||||
0x74, 0x75, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65,
|
0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04,
|
||||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x22, 0xbb, 0x04, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61,
|
0x52, 0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x22, 0x63, 0x0a, 0x09, 0x4c, 0x69, 0x6e, 0x6b,
|
||||||
0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x66,
|
0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x0e, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x52, 0x65,
|
||||||
0x69, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x66,
|
0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6f,
|
||||||
0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74,
|
0x72, 0x64, 0x65, 0x72, 0x52, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x12, 0x16, 0x0a,
|
||||||
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74, 0x50, 0x72,
|
0x06, 0x63, 0x61, 0x72, 0x74, 0x49, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x63,
|
||||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61, 0x64, 0x64,
|
0x61, 0x72, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
|
||||||
0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f,
|
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0x12, 0x0a,
|
||||||
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64,
|
0x10, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c,
|
||||||
0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x41,
|
0x65, 0x22, 0xbb, 0x04, 0x0a, 0x08, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3f,
|
||||||
0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65,
|
0x0a, 0x0b, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x01, 0x20,
|
||||||
0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f,
|
0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65,
|
||||||
0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c,
|
||||||
0x73, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48,
|
0x65, 0x48, 0x00, 0x52, 0x0a, 0x73, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12,
|
||||||
0x00, 0x52, 0x0d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
0x3f, 0x0a, 0x0b, 0x61, 0x64, 0x64, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02,
|
||||||
0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65,
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d,
|
||||||
0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69,
|
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65,
|
||||||
0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f,
|
0x73, 0x73, 0x48, 0x00, 0x52, 0x0a, 0x61, 0x64, 0x64, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73,
|
||||||
0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d,
|
0x12, 0x48, 0x0a, 0x0e, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65,
|
||||||
0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, 0x6c, 0x69,
|
0x73, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69,
|
||||||
0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
|
0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x55, 0x70, 0x64, 0x61,
|
||||||
|
0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x75, 0x70, 0x64,
|
||||||
|
0x61, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x48, 0x0a, 0x0e, 0x72, 0x65,
|
||||||
|
0x6d, 0x6f, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01,
|
||||||
|
0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73,
|
||||||
|
0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64, 0x72,
|
||||||
|
0x65, 0x73, 0x73, 0x48, 0x00, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x41, 0x64, 0x64,
|
||||||
|
0x72, 0x65, 0x73, 0x73, 0x12, 0x39, 0x0a, 0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x61, 0x72,
|
||||||
|
0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c,
|
||||||
|
0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43,
|
||||||
|
0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x12,
|
||||||
|
0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74,
|
||||||
|
0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65,
|
||||||
|
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68,
|
||||||
|
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00, 0x52, 0x0c, 0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68,
|
||||||
|
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c, 0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f,
|
||||||
|
0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69,
|
||||||
|
0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48, 0x00, 0x52, 0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f,
|
||||||
|
0x72, 0x64, 0x65, 0x72, 0x12, 0x51, 0x0a, 0x11, 0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a,
|
||||||
|
0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||||
|
0x22, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||||
|
0x65, 0x73, 0x2e, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66,
|
||||||
|
0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x10, 0x61, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65,
|
||||||
|
0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x42,
|
||||||
|
0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e, 0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d,
|
||||||
|
0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72, 0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f,
|
||||||
|
0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x3b,
|
||||||
0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
|
||||||
0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x61, 0x72, 0x74, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x69, 0x6e,
|
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
0x6b, 0x43, 0x61, 0x72, 0x74, 0x12, 0x45, 0x0a, 0x0d, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x63, 0x68,
|
|
||||||
0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70,
|
|
||||||
0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
|
|
||||||
0x4c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x48, 0x00, 0x52, 0x0c,
|
|
||||||
0x6c, 0x69, 0x6e, 0x6b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x6f, 0x75, 0x74, 0x12, 0x3c, 0x0a, 0x0a,
|
|
||||||
0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b,
|
|
||||||
0x32, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61,
|
|
||||||
0x67, 0x65, 0x73, 0x2e, 0x4c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x48, 0x00, 0x52,
|
|
||||||
0x09, 0x6c, 0x69, 0x6e, 0x6b, 0x4f, 0x72, 0x64, 0x65, 0x72, 0x12, 0x51, 0x0a, 0x11, 0x61, 0x6e,
|
|
||||||
0x6f, 0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x18,
|
|
||||||
0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f,
|
|
||||||
0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x41, 0x6e, 0x6f, 0x6e, 0x79, 0x6d, 0x69,
|
|
||||||
0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x48, 0x00, 0x52, 0x10, 0x61, 0x6e, 0x6f,
|
|
||||||
0x6e, 0x79, 0x6d, 0x69, 0x7a, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x42, 0x06, 0x0a,
|
|
||||||
0x04, 0x74, 0x79, 0x70, 0x65, 0x42, 0x3f, 0x5a, 0x3d, 0x67, 0x69, 0x74, 0x2e, 0x6b, 0x36, 0x6e,
|
|
||||||
0x2e, 0x6e, 0x65, 0x74, 0x2f, 0x6d, 0x61, 0x74, 0x73, 0x2f, 0x67, 0x6f, 0x2d, 0x63, 0x61, 0x72,
|
|
||||||
0x74, 0x2d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72,
|
|
||||||
0x6f, 0x66, 0x69, 0x6c, 0x65, 0x3b, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x6d, 0x65,
|
|
||||||
0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
Reference in New Issue
Block a user