144 lines
3.7 KiB
Go
144 lines
3.7 KiB
Go
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.Chmod(0644); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
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)
|
|
}
|