diff options
| author | historia <[not public]> | 2026-09-15 19:55:21 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 19:55:21 -0400 |
| commit | 6f3c43aca62a7d33a19f14f6ad7c16891952673a (patch) | |
| tree | 0b0665ef624ef442df87ec7131129aaecc3420bd /internal/admin/api_rooms_bulk.go | |
| parent | cd95de60364aba8f5bdf8ec86f7234a9903eacc8 (diff) | |
| download | thehouseoficarus-6f3c43aca62a7d33a19f14f6ad7c16891952673a.tar.gz | |
fix: outstanding-stake ledger, owner backpressure, editor state preservation
Diffstat (limited to 'internal/admin/api_rooms_bulk.go')
| -rw-r--r-- | internal/admin/api_rooms_bulk.go | 146 |
1 files changed, 65 insertions, 81 deletions
diff --git a/internal/admin/api_rooms_bulk.go b/internal/admin/api_rooms_bulk.go index 6283067..a7fcee7 100644 --- a/internal/admin/api_rooms_bulk.go +++ b/internal/admin/api_rooms_bulk.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "strconv" + + "thehouseoficarus/internal/persistence" ) type bulkEditPatch struct { @@ -22,6 +24,17 @@ type bulkEditRequest struct { IDs []int `json:"ids"` } +// handleBulkEdit applies one patch to many rooms as a single mutation +// plan: +// +// 1. Preflight reads, parses, patches, and marshals every selected room in +// memory — including destination-directory and conflict checks for +// moves. Any failure aborts BEFORE any file is touched: a failed bulk +// edit never leaves partial changes behind. +// 2. The writes apply as one journal transaction (in-place replaces, and +// move+replace for real directory moves) that rolls back on failure. +// 3. One undo record captures every before/after image, so undo restores +// the whole plan. func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -53,43 +66,27 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { patch := req.Patch - roomPaths := make([]string, 0, len(req.IDs)) - for _, id := range req.IDs { - path, ok := s.world.GetRoomPath(id) - if !ok { - writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) - return - } - roomPaths = append(roomPaths, path) - } - type roomWrite struct { - oldContent []byte - newContent []byte - oldPath string - newPath string - wasMoved bool + id int + oldPath, newPath string + oldContent, newContent []byte } + // ---- Preflight: everything that can fail happens here, before any + // file is modified. ---- writes := make([]roomWrite, 0, len(req.IDs)) - dirMovePaths := make(map[string]struct{}) - - for i, id := range req.IDs { - path := roomPaths[i] - - oldContent, snapErr := snapshotFile(path) - if snapErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("snapshot room %d: %v", id, snapErr)}) + for _, id := range req.IDs { + path, ok := s.world.GetRoomPath(id) + if !ok { + writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d not found", id)}) return } - - data, readErr := os.ReadFile(path) - if readErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, readErr)}) + oldContent, err := os.ReadFile(path) + if err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("read room %d: %v", id, err)}) return } - - m, yamlErr := yamlToMap(data) + m, yamlErr := yamlToMap(oldContent) if yamlErr != nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("parse room %d: %v", id, yamlErr)}) return @@ -109,63 +106,50 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { } } + newPath := path if patch.Dir != nil && *patch.Dir != "" { destDir := filepath.Join(s.dataDir, "rooms", *patch.Dir) - if _, err := os.Stat(destDir); os.IsNotExist(err) { + if _, statErr := os.Stat(destDir); os.IsNotExist(statErr) { writeJSON(w, map[string]any{"error": fmt.Sprintf("directory does not exist: %s", *patch.Dir)}) return } - - fileName := strconv.Itoa(id) + ".yaml" - newPath := filepath.Join(destDir, fileName) + newPath = filepath.Join(destDir, strconv.Itoa(id)+".yaml") if newPath != path { - if _, err := os.Stat(newPath); err == nil { + if _, statErr := os.Stat(newPath); statErr == nil { writeJSON(w, map[string]any{"error": fmt.Sprintf("room %d already exists in target directory", id)}) return } } + // newPath == path is an in-place edit: the room already lives + // in the target directory and must NOT be removed after its + // write (that deleted existing rooms). + } - newContent, writeErr := writeMapAsYAML(newPath, m) - if writeErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)}) - return - } - - writes = append(writes, roomWrite{ - oldContent: oldContent, - newContent: newContent, - oldPath: path, - newPath: newPath, - wasMoved: true, - }) - dirMovePaths[path] = struct{}{} - } else { - newContent, writeErr := writeMapAsYAML(path, m) - if writeErr != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", id, writeErr)}) - return - } - - writes = append(writes, roomWrite{ - oldContent: oldContent, - newContent: newContent, - oldPath: path, - newPath: path, - wasMoved: false, - }) + newContent, marshalErr := marshalMapYAML(m) + if marshalErr != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("marshal room %d: %v", id, marshalErr)}) + return } + writes = append(writes, roomWrite{id: id, oldPath: path, newPath: newPath, oldContent: oldContent, newContent: newContent}) } - for _, rw := range writes { - if rw.wasMoved { - if _, alreadyRemoved := dirMovePaths[rw.oldPath]; alreadyRemoved { - delete(dirMovePaths, rw.oldPath) - if err := os.Remove(rw.oldPath); err != nil { - writeJSON(w, map[string]any{"error": fmt.Sprintf("remove old room: %v", err)}) - return - } + // ---- Apply as one journaled transaction. ---- + txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal")) + for _, wr := range writes { + if wr.newPath != wr.oldPath { + if err := txn.Move(wr.oldPath, wr.newPath); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("move room %d: %v", wr.id, err)}) + return } } + if err := txn.Replace(wr.newPath, wr.newContent, 0644); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("write room %d: %v", wr.id, err)}) + return + } + } + if err := txn.Commit(); err != nil { + writeJSON(w, map[string]any{"error": fmt.Sprintf("commit bulk edit: %v", err)}) + return } s.world.RebuildRoomIndex(s.dataDir) @@ -179,24 +163,24 @@ func (s *AdminServer) handleBulkEdit(w http.ResponseWriter, r *http.Request) { var extraFiles []ExtraFile var firstDesc ChangeDesc - for i, rw := range writes { + for i, wr := range writes { ef := ExtraFile{ - FilePath: rw.oldPath, - OldContent: rw.oldContent, - NewContent: rw.newContent, + FilePath: wr.oldPath, + OldContent: wr.oldContent, + NewContent: wr.newContent, } - if rw.wasMoved { - ef.NewFilePath = rw.newPath + if wr.newPath != wr.oldPath { + ef.NewFilePath = wr.newPath } if i == 0 { firstDesc = ChangeDesc{ Description: desc, - FilePath: rw.oldPath, - OldContent: rw.oldContent, - NewContent: rw.newContent, + FilePath: wr.oldPath, + OldContent: wr.oldContent, + NewContent: wr.newContent, } - if rw.wasMoved { - firstDesc.NewFilePath = rw.newPath + if wr.newPath != wr.oldPath { + firstDesc.NewFilePath = wr.newPath } } else { extraFiles = append(extraFiles, ef) |
