package synthetic import ( "encoding/json" "fmt" "hash/fnv" "os" "path/filepath" "sort" "strconv" "strings" "time" "unicode" "github.com/uopi/uopi/internal/datasource" ) // VersionMeta describes a single persisted revision of a synthetic signal, // mirroring storage.VersionMeta so the frontend treats every versioned document // type uniformly. type VersionMeta struct { Version int `json:"version"` Name string `json:"name"` Tag string `json:"tag,omitempty"` Current bool `json:"current"` SavedAt time.Time `json:"savedAt"` } func (s *Synthetic) versionsDir() string { return filepath.Join(s.storePath, "synthetic_versions") } // slugForFile maps an arbitrary signal name to a filesystem-safe stem. A short // hash of the full name is appended so distinct names that sanitise to the same // stem (e.g. "A:B" and "A_B") never share backup files. func slugForFile(name string) string { var b strings.Builder for _, r := range name { if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { b.WriteRune(r) } else { b.WriteByte('_') } } h := fnv.New32a() _, _ = h.Write([]byte(name)) return fmt.Sprintf("%s-%08x", b.String(), h.Sum32()) } func (s *Synthetic) versionPath(name string, version int) string { return filepath.Join(s.versionsDir(), fmt.Sprintf("%s.v%d.json", slugForFile(name), version)) } // backupVersion writes a single signal revision to the versions directory. func (s *Synthetic) backupVersion(def SignalDef) error { if err := os.MkdirAll(s.versionsDir(), 0o755); err != nil { return err } data, err := json.MarshalIndent(def, "", " ") if err != nil { return err } return os.WriteFile(s.versionPath(def.Name, def.Version), data, 0o644) } // Versions returns metadata for every persisted revision of the named signal, // newest first. The live revision is flagged Current. func (s *Synthetic) Versions(name string) ([]VersionMeta, error) { cur, err := s.GetSignal(name) if err != nil { return nil, err } curV := cur.Version if curV < 1 { curV = 1 } var savedAt time.Time if info, err := os.Stat(s.defsFilePath()); err == nil { savedAt = info.ModTime() } out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}} entries, err := os.ReadDir(s.versionsDir()) if err != nil && !os.IsNotExist(err) { return nil, err } prefix := slugForFile(name) + ".v" for _, e := range entries { fn := e.Name() if e.IsDir() || !strings.HasPrefix(fn, prefix) || !strings.HasSuffix(fn, ".json") { continue } vStr := strings.TrimSuffix(strings.TrimPrefix(fn, prefix), ".json") v, err := strconv.Atoi(vStr) if err != nil { continue } def, err := s.readVersion(name, v) if err != nil { continue } info, err := e.Info() if err != nil { continue } out = append(out, VersionMeta{Version: v, Name: def.Name, Tag: def.Tag, SavedAt: info.ModTime()}) } sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version }) return out, nil } // GetVersion returns a specific revision of the named signal. The current // revision comes from the live store; older revisions from their backup file. func (s *Synthetic) GetVersion(name string, version int) (SignalDef, error) { cur, err := s.GetSignal(name) if err != nil { return SignalDef{}, err } curV := cur.Version if curV < 1 { curV = 1 } if version == curV { return cur, nil } return s.readVersion(name, version) } func (s *Synthetic) readVersion(name string, version int) (SignalDef, error) { data, err := os.ReadFile(s.versionPath(name, version)) if os.IsNotExist(err) { return SignalDef{}, datasource.ErrNotFound } if err != nil { return SignalDef{}, err } var def SignalDef if err := json.Unmarshal(data, &def); err != nil { return SignalDef{}, fmt.Errorf("parse revision: %w", err) } return def, nil } // PromoteVersion makes a past revision current by re-saving it on top of // history (non-destructive). Returns the resulting current definition. func (s *Synthetic) PromoteVersion(name string, version int) (SignalDef, error) { def, err := s.GetVersion(name, version) if err != nil { return SignalDef{}, err } def.Tag = fmt.Sprintf("restored from v%d", version) if err := s.UpdateSignal(def); err != nil { return SignalDef{}, err } return s.GetSignal(name) } // ForkVersion creates a brand-new synthetic signal from a specific revision, // assigning a fresh unique name and resetting its version to 1. func (s *Synthetic) ForkVersion(name string, version int) (SignalDef, error) { def, err := s.GetVersion(name, version) if err != nil { return SignalDef{}, err } def.Name = fmt.Sprintf("%s_fork_%d", name, time.Now().UnixMilli()) def.Version = 1 def.Tag = "" if err := s.AddSignal(def); err != nil { return SignalDef{}, err } return def, nil }