302 lines
9.0 KiB
Go
302 lines
9.0 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"sync"
|
|
)
|
|
|
|
// mutation_registry.go
|
|
//
|
|
// Mutation Registry Infrastructure
|
|
// --------------------------------
|
|
// This file introduces a generic registry for cart mutations that:
|
|
//
|
|
// 1. Decouples mutation logic from the large type-switch inside CartGrain.Apply.
|
|
// 2. Enforces (at registration time) that every mutation handler has the correct
|
|
// signature: func(*CartGrain, *T) error
|
|
// 3. Optionally auto-updates cart totals after a mutation if flagged.
|
|
// 4. Provides a single authoritative list of registered mutations for
|
|
// introspection / coverage testing.
|
|
// 5. Allows incremental migration: you can first register new mutations here,
|
|
// and later prune the legacy switch cases.
|
|
//
|
|
// Usage Pattern
|
|
// -------------
|
|
// // Define your mutation proto message (e.g. messages.ApplyCoupon in messages.proto)
|
|
// // Regenerate protobufs.
|
|
//
|
|
// // In an init() (ideally in a small file like mutations_apply_coupon.go)
|
|
// func init() {
|
|
// RegisterMutation[*messages.ApplyCoupon](
|
|
// "ApplyCoupon",
|
|
// func(g *CartGrain, m *messages.ApplyCoupon) error {
|
|
// // domain logic ...
|
|
// discount := int64(5000)
|
|
// if g.TotalPrice < discount {
|
|
// discount = g.TotalPrice
|
|
// }
|
|
// g.TotalDiscount += discount
|
|
// g.TotalPrice -= discount
|
|
// return nil
|
|
// },
|
|
// WithTotals(), // we changed price-related fields; recalc totals
|
|
// )
|
|
// }
|
|
//
|
|
// // To invoke dynamically (alternative to the current switch):
|
|
// if updated, err := ApplyRegistered(grain, incomingMessage); err == nil {
|
|
// grain = updated
|
|
// } else if errors.Is(err, ErrMutationNotRegistered) {
|
|
// // fallback to legacy switch logic
|
|
// }
|
|
//
|
|
// Migration Strategy
|
|
// ------------------
|
|
// 1. For each existing mutation handled in CartGrain.Apply, add a registry
|
|
// registration with equivalent logic.
|
|
// 2. Add a test that enumerates all *expected* mutation proto types and asserts
|
|
// they are present in RegisteredMutationTypes().
|
|
// 3. Once coverage is 100%, replace the switch in CartGrain.Apply with a call
|
|
// to ApplyRegistered (and optionally keep a minimal default to produce an
|
|
// "unsupported mutation" error).
|
|
//
|
|
// Thread Safety
|
|
// -------------
|
|
// Registration is typically done at init() time; a RWMutex provides safety
|
|
// should late dynamic registration ever be introduced.
|
|
//
|
|
// Auto Totals
|
|
// -----------
|
|
// Many mutations require recomputing totals. To avoid forgetting this, pass
|
|
// WithTotals() when registering. This will invoke grain.UpdateTotals() after
|
|
// the handler returns successfully.
|
|
//
|
|
// Error Semantics
|
|
// ---------------
|
|
// - If a handler returns an error, totals are NOT recalculated (even if
|
|
// WithTotals() was specified).
|
|
// - ApplyRegistered returns (nil, ErrMutationNotRegistered) if the message type
|
|
// is absent.
|
|
//
|
|
// Extensibility
|
|
// -------------
|
|
// It is straightforward to add options like audit hooks, metrics wrappers,
|
|
// or optimistic concurrency guards by extending MutationOption.
|
|
//
|
|
// NOTE: Generics require Go 1.18+. If constrained to earlier Go versions,
|
|
// replace the generic registration with a non-generic RegisterMutationType
|
|
// that accepts reflect.Type and an adapter function.
|
|
//
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var (
|
|
mutationRegistryMu sync.RWMutex
|
|
mutationRegistry = make(map[reflect.Type]*registeredMutation)
|
|
|
|
// ErrMutationNotRegistered is returned when no handler exists for a given mutation type.
|
|
ErrMutationNotRegistered = fmt.Errorf("mutation not registered")
|
|
)
|
|
|
|
// MutationOption configures additional behavior for a registered mutation.
|
|
type MutationOption func(*mutationOptions)
|
|
|
|
// mutationOptions holds flags adjustable per registration.
|
|
type mutationOptions struct {
|
|
updateTotals bool
|
|
}
|
|
|
|
// WithTotals ensures CartGrain.UpdateTotals() is called after a successful handler.
|
|
func WithTotals() MutationOption {
|
|
return func(o *mutationOptions) {
|
|
o.updateTotals = true
|
|
}
|
|
}
|
|
|
|
// registeredMutation stores metadata + the execution closure.
|
|
type registeredMutation struct {
|
|
name string
|
|
handler func(*CartGrain, interface{}) error
|
|
updateTotals bool
|
|
msgType reflect.Type
|
|
}
|
|
|
|
// RegisterMutation registers a mutation handler for a specific message type T.
|
|
//
|
|
// Parameters:
|
|
//
|
|
// name - a human-readable identifier (used for diagnostics / coverage tests).
|
|
// handler - business logic operating on the cart grain & strongly typed message.
|
|
// options - optional behavior flags (e.g., WithTotals()).
|
|
//
|
|
// Panics if:
|
|
// - name is empty
|
|
// - handler is nil
|
|
// - duplicate registration for the same message type T
|
|
//
|
|
// Typical call is placed in an init() function.
|
|
func RegisterMutation[T any](name string, handler func(*CartGrain, *T) error, options ...MutationOption) {
|
|
if name == "" {
|
|
panic("RegisterMutation: name is required")
|
|
}
|
|
if handler == nil {
|
|
panic("RegisterMutation: handler is nil")
|
|
}
|
|
|
|
// Derive the reflect.Type for *T then its Elem (T) for mapping.
|
|
var zero *T
|
|
rtPtr := reflect.TypeOf(zero)
|
|
if rtPtr.Kind() != reflect.Ptr {
|
|
panic("RegisterMutation: expected pointer type for generic parameter")
|
|
}
|
|
rt := rtPtr.Elem()
|
|
|
|
opts := mutationOptions{}
|
|
for _, opt := range options {
|
|
opt(&opts)
|
|
}
|
|
|
|
wrapped := func(g *CartGrain, m interface{}) error {
|
|
typed, ok := m.(*T)
|
|
if !ok {
|
|
return fmt.Errorf("mutation type mismatch: have %T want *%s", m, rt.Name())
|
|
}
|
|
return handler(g, typed)
|
|
}
|
|
|
|
mutationRegistryMu.Lock()
|
|
defer mutationRegistryMu.Unlock()
|
|
|
|
if _, exists := mutationRegistry[rt]; exists {
|
|
panic(fmt.Sprintf("RegisterMutation: duplicate registration for type %s", rt.String()))
|
|
}
|
|
|
|
mutationRegistry[rt] = ®isteredMutation{
|
|
name: name,
|
|
handler: wrapped,
|
|
updateTotals: opts.updateTotals,
|
|
msgType: rt,
|
|
}
|
|
}
|
|
|
|
// ApplyRegistered attempts to apply a registered mutation.
|
|
// Returns updated grain if successful.
|
|
//
|
|
// If the mutation is not registered, returns (nil, ErrMutationNotRegistered).
|
|
func ApplyRegistered(grain *CartGrain, msg interface{}) (*CartGrain, error) {
|
|
if grain == nil {
|
|
return nil, fmt.Errorf("nil grain")
|
|
}
|
|
if msg == nil {
|
|
return nil, fmt.Errorf("nil mutation message")
|
|
}
|
|
|
|
rt := indirectType(reflect.TypeOf(msg))
|
|
mutationRegistryMu.RLock()
|
|
entry, ok := mutationRegistry[rt]
|
|
mutationRegistryMu.RUnlock()
|
|
|
|
if !ok {
|
|
return nil, ErrMutationNotRegistered
|
|
}
|
|
|
|
if err := entry.handler(grain, msg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if entry.updateTotals {
|
|
grain.UpdateTotals()
|
|
}
|
|
|
|
return grain, nil
|
|
}
|
|
|
|
// RegisteredMutations returns metadata for all registered mutations (snapshot).
|
|
func RegisteredMutations() []string {
|
|
mutationRegistryMu.RLock()
|
|
defer mutationRegistryMu.RUnlock()
|
|
out := make([]string, 0, len(mutationRegistry))
|
|
for _, entry := range mutationRegistry {
|
|
out = append(out, entry.name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RegisteredMutationTypes returns the reflect.Type list of all registered messages.
|
|
// Useful for coverage tests ensuring expected set matches actual set.
|
|
func RegisteredMutationTypes() []reflect.Type {
|
|
mutationRegistryMu.RLock()
|
|
defer mutationRegistryMu.RUnlock()
|
|
out := make([]reflect.Type, 0, len(mutationRegistry))
|
|
for t := range mutationRegistry {
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// MustAssertMutationCoverage can be called at startup to ensure every expected
|
|
// mutation type has been registered. It panics with a descriptive message if any
|
|
// are missing. Provide a slice of prototype pointers (e.g. []*messages.AddItem{nil} ...)
|
|
func MustAssertMutationCoverage(expected []interface{}) {
|
|
mutationRegistryMu.RLock()
|
|
defer mutationRegistryMu.RUnlock()
|
|
|
|
missing := make([]string, 0)
|
|
for _, ex := range expected {
|
|
if ex == nil {
|
|
continue
|
|
}
|
|
t := indirectType(reflect.TypeOf(ex))
|
|
if _, ok := mutationRegistry[t]; !ok {
|
|
missing = append(missing, t.String())
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
panic(fmt.Sprintf("mutation registry missing handlers for: %v", missing))
|
|
}
|
|
}
|
|
|
|
// indirectType returns the element type if given a pointer; otherwise the type itself.
|
|
func indirectType(t reflect.Type) reflect.Type {
|
|
for t.Kind() == reflect.Ptr {
|
|
t = t.Elem()
|
|
}
|
|
return t
|
|
}
|
|
|
|
/*
|
|
Integration Guide
|
|
-----------------
|
|
|
|
1. Register all existing mutations:
|
|
|
|
func init() {
|
|
RegisterMutation[*messages.AddItem]("AddItem",
|
|
func(g *CartGrain, m *messages.AddItem) error {
|
|
// (port logic from existing switch branch)
|
|
// ...
|
|
return nil
|
|
},
|
|
WithTotals(),
|
|
)
|
|
// ... repeat for others
|
|
}
|
|
|
|
2. In CartGrain.Apply (early in the method) add:
|
|
|
|
if updated, err := ApplyRegistered(c, content); err == nil {
|
|
return updated, nil
|
|
} else if err != ErrMutationNotRegistered {
|
|
return nil, err
|
|
}
|
|
|
|
// existing switch fallback below
|
|
|
|
3. Once all mutations are registered, remove the legacy switch cases
|
|
and leave a single ErrMutationNotRegistered path for unknown types.
|
|
|
|
4. Add a coverage test (see docs for example; removed from source for clarity).
|
|
5. (Optional) Add metrics / tracing wrappers for handlers.
|
|
|
|
*/
|