mcp in cart
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-17 21:43:38 +02:00
parent ad410d217c
commit 74dc252279
9 changed files with 1054 additions and 13 deletions
+139
View File
@@ -0,0 +1,139 @@
package promotions
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// Store is a thread-safe, file-backed collection of promotion rules. It is the
// single source of truth shared by the cart's mutation processor (which reads a
// snapshot on every recompute) and the promotion MCP tools (which mutate it and
// persist to disk). Because the cart re-reads the snapshot each time totals are
// recomputed, MCP edits take effect on the next cart mutation — no restart.
type Store struct {
mu sync.RWMutex
path string
version int
promotions []PromotionRule
}
// NewStore loads the state file at path (an absent file yields an empty store)
// and returns a Store that persists back to the same path.
func NewStore(path string) (*Store, error) {
sf, err := LoadStateFile(path)
if err != nil {
return nil, err
}
version := sf.Version
if version == 0 {
version = 1
}
return &Store{
path: path,
version: version,
promotions: sf.State.Promotions,
}, nil
}
// Snapshot returns a copy of the current rules, safe to evaluate without holding
// the lock. The PromotionRule values are passed by value to EvaluateAll, so a
// shallow copy of the slice is enough to avoid racing with concurrent mutations.
func (s *Store) Snapshot() []PromotionRule {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]PromotionRule, len(s.promotions))
copy(out, s.promotions)
return out
}
// List returns a copy of all rules (for the list_promotions tool).
func (s *Store) List() []PromotionRule {
return s.Snapshot()
}
// Get returns the rule with the given id.
func (s *Store) Get(id string) (PromotionRule, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, r := range s.promotions {
if r.ID == id {
return r, true
}
}
return PromotionRule{}, false
}
// Upsert inserts the rule or replaces the existing one with the same ID, then
// persists. Returns true if an existing rule was replaced.
func (s *Store) Upsert(rule PromotionRule) (replaced bool, err error) {
if rule.ID == "" {
return false, fmt.Errorf("promotion id is required")
}
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.promotions {
if s.promotions[i].ID == rule.ID {
s.promotions[i] = rule
return true, s.saveLocked()
}
}
s.promotions = append(s.promotions, rule)
return false, s.saveLocked()
}
// SetStatus changes the status of a rule and persists. Returns false if no rule
// with the id exists.
func (s *Store) SetStatus(id string, status PromotionStatus) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.promotions {
if s.promotions[i].ID == id {
s.promotions[i].Status = status
return true, s.saveLocked()
}
}
return false, nil
}
// Delete removes the rule with the id and persists. Returns false if not found.
func (s *Store) Delete(id string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.promotions {
if s.promotions[i].ID == id {
s.promotions = append(s.promotions[:i], s.promotions[i+1:]...)
return true, s.saveLocked()
}
}
return false, nil
}
// saveLocked writes the current state to disk atomically. Caller holds s.mu.
func (s *Store) saveLocked() error {
sf := StateFile{
Version: s.version,
State: PromotionState{Promotions: s.promotions},
}
data, err := json.MarshalIndent(sf, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(s.path)
tmp, err := os.CreateTemp(dir, ".promotions-*.json")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op once renamed
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, s.path)
}