package controllogic import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "sync" "time" ) const definitionsFile = "controllogic.json" // ErrNotFound is returned when a graph id does not exist. var ErrNotFound = errors.New("control logic graph not found") // Store persists control-logic graphs as a single JSON file in the storage dir. // Writes are atomic (tmp file + rename); all access is mutex-guarded. // // Git-style versioning: the live graph lives in controllogic.json (the current // revision), while every superseded revision is preserved as a backup file // {id}.vN.json under versionsDir. Promote/Fork build on these backups. type Store struct { mu sync.RWMutex path string trashDir string versionsDir string items map[string]Graph } // NewStore opens (or initialises) the control-logic store under storageDir. func NewStore(storageDir string) (*Store, error) { s := &Store{ path: filepath.Join(storageDir, definitionsFile), trashDir: filepath.Join(storageDir, "trash", "controllogic"), versionsDir: filepath.Join(storageDir, "controllogic_versions"), items: map[string]Graph{}, } if err := s.load(); err != nil { return nil, err } return s, nil } func (s *Store) load() error { data, err := os.ReadFile(s.path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } var graphs []Graph if err := json.Unmarshal(data, &graphs); err != nil { return fmt.Errorf("parse %s: %w", s.path, err) } for _, g := range graphs { s.items[g.ID] = g } return nil } // saveLocked writes the current set atomically. Caller must hold s.mu. func (s *Store) saveLocked() error { graphs := make([]Graph, 0, len(s.items)) for _, g := range s.items { graphs = append(graphs, g) } data, err := json.MarshalIndent(graphs, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0o644); err != nil { return err } return os.Rename(tmp, s.path) } // List returns all graphs. func (s *Store) List() []Graph { s.mu.RLock() defer s.mu.RUnlock() out := make([]Graph, 0, len(s.items)) for _, g := range s.items { out = append(out, g) } return out } // Get returns a single graph by id. func (s *Store) Get(id string) (Graph, error) { s.mu.RLock() defer s.mu.RUnlock() g, ok := s.items[id] if !ok { return Graph{}, ErrNotFound } return g, nil } // Save inserts or replaces a graph and persists the store. When replacing an // existing graph the superseded revision is backed up as {id}.vN.json and the // new graph's Version is bumped; a brand-new graph starts at version 1. func (s *Store) Save(g Graph) error { s.mu.Lock() defer s.mu.Unlock() if old, ok := s.items[g.ID]; ok { oldV := old.Version if oldV < 1 { oldV = 1 old.Version = 1 } if err := s.backupLocked(old); err != nil { return fmt.Errorf("back up control logic revision: %w", err) } g.Version = oldV + 1 } else if g.Version < 1 { g.Version = 1 } s.items[g.ID] = g return s.saveLocked() } // backupLocked writes a single graph revision to versionsDir as {id}.vN.json. // Caller must hold s.mu. func (s *Store) backupLocked(g Graph) error { if err := os.MkdirAll(s.versionsDir, 0o755); err != nil { return err } data, err := json.MarshalIndent(g, "", " ") if err != nil { return err } return os.WriteFile(s.versionPath(g.ID, g.Version), data, 0o644) } func (s *Store) versionPath(id string, version int) string { return filepath.Join(s.versionsDir, fmt.Sprintf("%s.v%d.json", id, version)) } // Delete removes a graph by id, first writing a copy into the trash folder so it // can be recovered if needed. The trash backup is best-effort: a failure to write // it does not prevent the delete (but is reported). func (s *Store) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() g, ok := s.items[id] if !ok { return ErrNotFound } if err := s.trash(g); err != nil { return fmt.Errorf("move control logic to trash: %w", err) } delete(s.items, id) return s.saveLocked() } // trash writes a single graph as a timestamped JSON file under the trash folder. func (s *Store) trash(g Graph) error { if err := os.MkdirAll(s.trashDir, 0o755); err != nil { return err } data, err := json.MarshalIndent(g, "", " ") if err != nil { return err } dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli())) return os.WriteFile(dst, data, 0o644) }