tracker/diagram_history.go
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
type DiagramRevision struct {
Diagram
SavedAt string `json:"saved_at"`
Action string `json:"action"`
RestoredFrom int `json:"restored_from,omitempty"`
}
type diagramDocument struct {
Diagram
History []DiagramRevision `json:"history,omitempty"`
Approvals []DiagramApproval `json:"approvals,omitempty"`
}
var errDiagramRevisionNotFound = errors.New("diagram revision not found")
func (ds *diagramStore) readDocument(id string) (diagramDocument, error) {
var doc diagramDocument
b, err := os.ReadFile(filepath.Join(ds.dir, "diagrams", id+".json"))
if err != nil {
return doc, err
}
if err = json.Unmarshal(b, &doc); err != nil {
return doc, err
}
if err = validateDiagram(doc.Diagram); err != nil {
return doc, err
}
seen := map[int]bool{}
for _, h := range doc.History {
if h.Revision <= 0 || h.Revision > doc.Revision || seen[h.Revision] {
return doc, errors.New("invalid diagram history")
}
if err = validateDiagram(h.Diagram); err != nil {
return doc, err
}
seen[h.Revision] = true
}
if doc.Revision > 0 && !seen[doc.Revision] {
doc.History = append(doc.History, DiagramRevision{Diagram: doc.Diagram, Action: "existing"})
}
return doc, nil
}
func (ds *diagramStore) writeDocument(id string, doc diagramDocument) error {
dir := filepath.Join(ds.dir, "diagrams")
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
f, err := os.CreateTemp(dir, ".diagram-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(append(b, '\n')); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), filepath.Join(dir, id+".json"))
}
// Caller holds ds.mu. Snapshot and current graph are committed in one atomic file.
func (ds *diagramStore) writeRevision(id string, doc diagramDocument, d Diagram, action string, from int) (Diagram, error) {
d.Revision = doc.Revision + 1
if err := validateDiagram(d); err != nil {
return d, err
}
doc.Diagram = d
doc.History = append(doc.History, DiagramRevision{Diagram: d, SavedAt: time.Now().In(ict).Format(time.RFC3339), Action: action, RestoredFrom: from})
return d, ds.writeDocument(id, doc)
}
func (ds *diagramStore) revisions(id string) ([]DiagramRevision, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
doc, err := ds.readDocument(id)
if errors.Is(err, os.ErrNotExist) {
return []DiagramRevision{}, nil
}
return doc.History, err
}
func (ds *diagramStore) revision(id string, revision int) (DiagramRevision, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
doc, err := ds.readDocument(id)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return DiagramRevision{}, errDiagramRevisionNotFound
}
return DiagramRevision{}, err
}
for _, h := range doc.History {
if h.Revision == revision {
return h, nil
}
}
return DiagramRevision{}, errDiagramRevisionNotFound
}
func (ds *diagramStore) restore(id string, expected, target int) (Diagram, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
doc, err := ds.readDocument(id)
if errors.Is(err, os.ErrNotExist) {
return Diagram{}, errDiagramRevisionNotFound
}
if err != nil {
return Diagram{}, err
}
if doc.Revision != expected {
return Diagram{}, errDiagramConflict
}
for _, h := range doc.History {
if h.Revision == target {
return ds.writeRevision(id, doc, h.Diagram, "restore", target)
}
}
return Diagram{}, errDiagramRevisionNotFound
}
func decodeDiagramRequest(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
http.Error(w, "Invalid request", 400)
return false
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
http.Error(w, "Invalid request", 400)
return false
}
return true
}
func diagramResponse(w http.ResponseWriter, value any, err error) {
switch {
case errors.Is(err, errDiagramConflict):
http.Error(w, "A newer revision exists. Reload before retrying.", 409)
case errors.Is(err, errDiagramRevisionNotFound):
http.Error(w, "Revision not found", 404)
case err != nil:
http.Error(w, "Unable to read or save diagram", 500)
default:
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
json.NewEncoder(w).Encode(value)
}
}
func useCaseForRequest(w http.ResponseWriter, r *http.Request) (UseCase, bool) {
c, err := loadUseCases(repoRoot)
if err != nil {
http.Error(w, "Unable to read use cases", 500)
return UseCase{}, false
}
u, ok := c.UC[r.PathValue("uid")]
if !ok {
http.NotFound(w, r)
}
return u, ok
}
func registerDiagramHistory(mux *http.ServeMux, ds *diagramStore) {
mux.HandleFunc("GET /api/use-cases/{uid}/revisions", func(w http.ResponseWriter, r *http.Request) {
if _, ok := useCaseForRequest(w, r); !ok {
return
}
snapshots, err := ds.revisions(r.PathValue("uid"))
items := []map[string]any{}
for _, s := range snapshots {
items = append(items, map[string]any{"revision": s.Revision, "saved_at": s.SavedAt, "action": s.Action, "restored_from": s.RestoredFrom, "node_count": len(s.Nodes), "edge_count": len(s.Edges)})
}
diagramResponse(w, items, err)
})
mux.HandleFunc("GET /api/use-cases/{uid}/revisions/{revision}", func(w http.ResponseWriter, r *http.Request) {
if _, ok := useCaseForRequest(w, r); !ok {
return
}
rev, err := strconv.Atoi(r.PathValue("revision"))
if err != nil {
http.NotFound(w, r)
return
}
d, err := ds.revision(r.PathValue("uid"), rev)
diagramResponse(w, d, err)
})
mux.HandleFunc("POST /api/use-cases/{uid}/restore", func(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "Forbidden", 403)
return
}
if _, ok := useCaseForRequest(w, r); !ok {
return
}
var sub struct {
Revision int `json:"revision"`
Target int `json:"target_revision"`
}
if !decodeDiagramRequest(w, r, &sub) {
return
}
if sub.Revision < 0 || sub.Target <= 0 {
http.Error(w, "Invalid revision", 400)
return
}
d, err := ds.restore(r.PathValue("uid"), sub.Revision, sub.Target)
diagramResponse(w, d, err)
})
}