aboutsummaryrefslogtreecommitdiff
path: root/internal/admin
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 15:35:52 -0400
committerhistoria <[not public]>2026-09-15 15:35:52 -0400
commit0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch)
tree2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/admin
parent396c3facb8287b9a34076cae8f3d7b300d366d99 (diff)
downloadthehouseoficarus-0b0b25222b3131ada79ff78c28eb8bf07d977deb.tar.gz
feat: single-owner game state, transactional lifecycle, durable money on server stop
Diffstat (limited to 'internal/admin')
-rw-r--r--internal/admin/api_helpers.go19
-rw-r--r--internal/admin/api_room_insert_remove.go5
-rw-r--r--internal/admin/api_room_rename_test.go149
-rw-r--r--internal/admin/api_rooms.go149
-rw-r--r--internal/admin/api_rooms_bulk_delete.go3
-rw-r--r--internal/admin/server.go11
-rw-r--r--internal/admin/undo.go178
-rw-r--r--internal/admin/yaml_util.go15
8 files changed, 440 insertions, 89 deletions
diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go
index 109bca6..3224598 100644
--- a/internal/admin/api_helpers.go
+++ b/internal/admin/api_helpers.go
@@ -46,16 +46,27 @@ func stringifyMap(v any) any {
}
func writeMapAsYAML(path string, m map[string]any) ([]byte, error) {
+ content, err := marshalMapYAML(m)
+ if err != nil {
+ return nil, err
+ }
+ if err := os.WriteFile(path, content, 0644); err != nil {
+ return nil, err
+ }
+ return content, nil
+}
+
+// marshalMapYAML serializes a generic map without writing: callers that
+// only need the after-image (mutation-plan preflight) use this.
+func marshalMapYAML(m map[string]any) ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(m); err != nil {
return nil, err
}
- enc.Close()
- content := buf.Bytes()
- if err := os.WriteFile(path, content, 0644); err != nil {
+ if err := enc.Close(); err != nil {
return nil, err
}
- return content, nil
+ return buf.Bytes(), nil
}
diff --git a/internal/admin/api_room_insert_remove.go b/internal/admin/api_room_insert_remove.go
index 8549d76..812a3ae 100644
--- a/internal/admin/api_room_insert_remove.go
+++ b/internal/admin/api_room_insert_remove.go
@@ -349,6 +349,11 @@ func (s *AdminServer) handleRoomRemove(w http.ResponseWriter, r *http.Request) {
if s.mobStore != nil {
s.mobStore.RemoveMobsInRoom(bID)
}
+ // Deleted room: evict its cached games (wagers refunded via the wired
+ // game callback) so gameplay never points at a removed room.
+ if s.evictRoom != nil {
+ s.evictRoom(bID)
+ }
s.undoStack.Push(ChangeDesc{
Description: fmt.Sprintf("remove room %d %s (pull %d to %d)", bID, dir, cID, aID),
FilePath: bPath,
diff --git a/internal/admin/api_room_rename_test.go b/internal/admin/api_room_rename_test.go
new file mode 100644
index 0000000..8d273f4
--- /dev/null
+++ b/internal/admin/api_room_rename_test.go
@@ -0,0 +1,149 @@
+package admin
+
+// Room-rename mutation-plan regressions: the rename, the reference rewrites
+// (including self-loops), the runtime migration callback, undo, redo, and
+// injected partial-write failures must all agree.
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func renameRoom(t *testing.T, s *AdminServer, id, newID int) *httptest.ResponseRecorder {
+ t.Helper()
+ body, _ := json.Marshal(map[string]int{"id": id, "new_id": newID})
+ req := httptest.NewRequest(http.MethodPost, "/api/room/rename", strings.NewReader(string(body)))
+ rec := httptest.NewRecorder()
+ s.handleRoomRename(rec, req)
+ return rec
+}
+
+func roomBody(t *testing.T, s *AdminServer, id int) string {
+ t.Helper()
+ path, ok := s.world.GetRoomPath(id)
+ if !ok {
+ return ""
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(data)
+}
+
+// TestRoomRenameFullPlanCoversReferencesAndSelfLoop: renaming a room that a
+// neighbor references and that references itself rewrites the neighbor, the
+// renamed room's own id field AND self-loop, moves the file, and records the
+// whole plan for undo.
+func TestRoomRenameFullPlan(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 5: "id: 5\nname: Lab\nexits:\n north:\n room: 6\n east:\n room: 5\n",
+ 6: "id: 6\nname: Hall\nexits:\n south:\n room: 5\n",
+ })
+
+ var migrated []map[int]int
+ // The wired callback mirrors production (the game's MigrateRoomIDs):
+ // record the map and rebuild the world room index after migration.
+ migrate := func(idMap map[int]int) {
+ recorded := map[int]int{}
+ for k, v := range idMap {
+ recorded[k] = v
+ }
+ migrated = append(migrated, recorded)
+ s.world.RebuildRoomIndex(s.dataDir)
+ }
+ s.rewriteRoomIDs = migrate
+ s.undoStack.SetMigrateRoomIDs(migrate)
+
+ rec := renameRoom(t, s, 5, 7)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("rename failed: %d %s", rec.Code, rec.Body.String())
+ }
+
+ // Renamed room file: moved, id field rewritten, self-loop corrected.
+ selfBody := roomBody(t, s, 7)
+ if !strings.Contains(selfBody, "room: 7") {
+ t.Fatalf("renamed room self-loop not rewritten: %q", selfBody)
+ }
+ if _, err := os.Stat(filepath.Join(s.dataDir, "rooms", "5.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("old room file still exists: %v", err)
+ }
+ // The referencing room's exit moved.
+ hallBody := roomBody(t, s, 6)
+ if !strings.Contains(hallBody, "room: 7") {
+ t.Fatalf("reference rewrite missing: %q", hallBody)
+ }
+ // The runtime migration callback ran with the forward map.
+ if len(migrated) != 1 {
+ t.Fatalf("runtime migration callback not invoked with one plan: %v", migrated)
+ }
+
+ // Undo: everything returns to before-images, inverse migration runs.
+ if s.undoStack.Undo() == nil {
+ t.Fatal("undo failed")
+ }
+ if roomBody(t, s, 5) == "" {
+ t.Fatal("undo did not restore the renamed room at its old path")
+ }
+ if _, err := os.Stat(filepath.Join(s.dataDir, "rooms", "7.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("undo left the new room file behind: %v", err)
+ }
+ if hall := roomBody(t, s, 6); strings.Contains(hall, "room: 7") {
+ t.Fatalf("undo did not restore the referencing room: %q", hall)
+ }
+ // The last migration callback must be the inverse map.
+ if last := migrated[len(migrated)-1]; !(len(last) == 1 && last[7] == 5) {
+ t.Fatalf("undo migration callback = %v, want inverse {7:5}", last)
+ }
+
+ // Redo: forward again, whole plan re-applied.
+ if s.undoStack.Redo() == nil {
+ t.Fatal("redo failed")
+ }
+ if !strings.Contains(roomBody(t, s, 6), "room: 7") {
+ t.Fatal("redo did not re-apply the reference rewrite")
+ }
+ if roomBody(t, s, 5) != "" {
+ t.Fatal("redo resurrected the old room file")
+ }
+ if last := migrated[len(migrated)-1]; !(len(last) == 1 && last[5] == 7) {
+ t.Fatalf("redo migration callback = %v, want forward {5:7}", last)
+ }
+}
+
+// TestRoomRenameAbortsOnWriteFailure: when the write side fails mid-plan,
+// the journal transaction rolls back — the old room file and its references
+// stay untouched, and no half-renamed state survives.
+func TestRoomRenameAbortsOnWriteFailure(t *testing.T) {
+ s := newTestAdminServer(t, map[int]string{
+ 5: "id: 5\nname: Lab\nexits:\n north:\n room: 6\n",
+ 6: "id: 6\nname: Hall\nexits:\n south:\n room: 5\n",
+ })
+
+ // A read-only rooms directory makes every room-file write fail.
+ roomsDir := filepath.Join(s.dataDir, "rooms")
+ if err := os.Chmod(roomsDir, 0500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(roomsDir, 0700) })
+
+ rec := renameRoom(t, s, 5, 7)
+ if rec.Code == http.StatusOK {
+ t.Fatalf("rename succeeded despite the write failure: %s", rec.Body.String())
+ }
+ os.Chmod(roomsDir, 0700)
+
+ // Nothing was renamed: room 5 still exists at its old path with its
+ // original content, and room 7 does not exist.
+ if body := roomBody(t, s, 5); !strings.Contains(body, "id: 5") {
+ t.Fatalf("room 5 damaged by failed rename: %q", body)
+ }
+ if roomExists(s, 7) {
+ t.Fatal("room 7 exists after the failed rename")
+ }
+}
diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go
index 2c251bb..4cfb467 100644
--- a/internal/admin/api_rooms.go
+++ b/internal/admin/api_rooms.go
@@ -12,6 +12,7 @@ import (
"strings"
"thehouseoficarus/internal/behavior"
+ "thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/world"
)
@@ -840,6 +841,18 @@ func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "path": newPath})
}
+// handleRoomRename executes a room-ID rename as one complete mutation plan:
+//
+// 1. Preflight reads every room file and computes after-images in memory
+// (the renamed room — id field, filename, and self-loop references — plus
+// every other room whose references moved). Load failures abort BEFORE
+// any mutation; nothing is half-applied.
+// 2. The file changes apply as a single journal transaction, rolling back
+// on any write failure.
+// 3. The wired runtime migration (players, Hub buckets, casino room keys,
+// world room-keyed state, mob instances) runs through one owner task.
+// 4. The undo record captures every before/after image and the idMap, so
+// undo/redo restore the whole plan, not just the renamed file.
func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -862,12 +875,10 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, "room not found", http.StatusBadRequest)
return
}
-
if _, ok := s.world.GetRoomPath(body.NewID); ok {
writeJSONError(w, fmt.Sprintf("room %d already exists", body.NewID), http.StatusConflict)
return
}
-
dir := filepath.Dir(oldPath)
newPath := filepath.Join(dir, strconv.Itoa(body.NewID)+".yaml")
if _, err := os.Stat(newPath); err == nil {
@@ -875,63 +886,125 @@ func (s *AdminServer) handleRoomRename(w http.ResponseWriter, r *http.Request) {
return
}
- data, err := os.ReadFile(oldPath)
- if err != nil {
- writeJSONError(w, "read: "+err.Error(), http.StatusInternalServerError)
+ idMap := map[int]int{body.ID: body.NewID}
+
+ // ---- Preflight: read every room file and compute after-images in
+ // memory before mutating anything. Load or marshal failures abort the
+ // request with no half-applied state. ----
+ allIDs, _ := listRoomIDs(s.dataDir)
+ type roomImage struct {
+ path string
+ before []byte
+ after []byte
+ changed bool
+ }
+ images := make(map[int]*roomImage, len(allIDs))
+ for _, roomID := range allIDs {
+ path, pathOk := s.world.GetRoomPath(roomID)
+ if !pathOk {
+ writeJSONError(w, fmt.Sprintf("room %d has no file path", roomID), http.StatusInternalServerError)
+ return
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ writeJSONError(w, "read: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ img := &roomImage{path: path, before: data, after: data}
+
+ room, loadErr := s.world.LoadRoom(roomID)
+ if loadErr != nil {
+ writeJSONError(w, fmt.Sprintf("load room %d: %v", roomID, loadErr), http.StatusInternalServerError)
+ return
+ }
+ moved := room.RewriteRoomIDs(idMap)
+ if roomID == body.ID {
+ // The renamed room's own id field becomes the new ID (the
+ // file index keys by filename, but the room's self-view must
+ // agree with it).
+ room.ID = body.NewID
+ moved = true
+ }
+ if moved {
+ after, err := marshalYAML(room)
+ if err != nil {
+ writeJSONError(w, "marshal: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ img.after, img.changed = after, true
+ }
+ images[roomID] = img
+ }
+ renamed, ok := images[body.ID]
+ if !ok {
+ writeJSONError(w, "renamed room missing from plan", http.StatusInternalServerError)
return
}
- if err := os.WriteFile(newPath, data, 0644); err != nil {
- writeJSONError(w, "write: "+err.Error(), http.StatusInternalServerError)
+
+ // ---- Apply the file changes as one journal transaction. ----
+ txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal"))
+ if err := txn.Move(oldPath, newPath); err != nil {
+ writeJSONError(w, "plan move: "+err.Error(), http.StatusInternalServerError)
return
}
- if err := os.Remove(oldPath); err != nil {
- writeJSONError(w, "remove old: "+err.Error(), http.StatusInternalServerError)
+ // The renamed room's rewritten content (new id field + corrected
+ // self-loop) lands via a replace on the new path right after the move.
+ if err := txn.Replace(newPath, renamed.after, 0644); err != nil {
+ writeJSONError(w, "plan self write: "+err.Error(), http.StatusInternalServerError)
return
}
-
- s.world.RebuildRoomIndex(s.dataDir)
- s.world.ClearRoomState(body.ID)
-
- // Remap every genuine room-ID reference (exit targets, trigger/on-enter
- // room+teleport+despawn_rooms, mob wander_rooms) from the old ID to the
- // new one across all rooms — including the renamed room itself, so a
- // self-loop exit is corrected too. Only rooms whose references actually
- // moved are re-marshaled (preserving untouched files' formatting).
- idMap := map[int]int{body.ID: body.NewID}
- allIDs, _ := listRoomIDs(s.dataDir)
var refUpdates int
- for _, otherID := range allIDs {
- otherRoom, loadErr := s.world.LoadRoom(otherID)
- if loadErr != nil {
- continue
- }
- if !otherRoom.RewriteRoomIDs(idMap) {
+ for roomID, img := range images {
+ if !img.changed || roomID == body.ID {
continue
}
- otherPath, pathOk := s.world.GetRoomPath(otherID)
- if !pathOk {
- continue
+ if err := txn.Replace(img.path, img.after, 0644); err != nil {
+ writeJSONError(w, "plan write: "+err.Error(), http.StatusInternalServerError)
+ return
}
- writeYAMLFile(otherPath, otherRoom)
refUpdates++
}
+ if err := txn.Commit(); err != nil {
+ writeJSONError(w, "commit rename: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ s.world.RebuildRoomIndex(s.dataDir)
+ s.world.ClearRoomState(body.ID)
// Migrate room-ID-embedded state across all characters (discovered-exit
- // player flags, RoomID, EnterSeqRoom, MapSymbols, RoomsVisited) so a
- // rename does not strand players or leave stale hidden_exit_<id>_<dir>
- // flags pointing at the old room ID.
+ // player flags, RoomID, EnterSeqRoom, MapSymbols, RoomsVisited) plus
+ // Hub membership buckets, casino room keys, world room-keyed state, and
+ // live mob instances, so a rename leaves no gameplay, broadcast, or
+ // player attached to the old location.
if s.rewriteRoomIDs != nil {
s.rewriteRoomIDs(idMap)
}
- s.undoStack.Push(ChangeDesc{
+ // ---- Undo record: the complete plan (renamed room before/after images
+ // including the rewritten id field and self-loop references, every
+ // rewritten reference room, and the idMap for inverse runtime
+ // migration). Undo/redo restore the whole plan, not just the renamed
+ // file. ----
+ change := ChangeDesc{
Description: fmt.Sprintf("rename room %d to %d (updated %d refs)", body.ID, body.NewID, refUpdates),
FilePath: oldPath,
NewFilePath: newPath,
- OldContent: data,
- NewContent: data,
- })
- writeJSON(w, map[string]any{"ok": true})
+ OldContent: renamed.before,
+ NewContent: renamed.after,
+ IDMap: idMap,
+ }
+ for roomID, img := range images {
+ if img.changed && roomID != body.ID {
+ change.ExtraFiles = append(change.ExtraFiles, ExtraFile{
+ FilePath: img.path,
+ OldContent: img.before,
+ NewContent: img.after,
+ })
+ }
+ }
+ s.undoStack.Push(change)
+ writeJSON(w, map[string]any{"ok": true, "refs": refUpdates})
}
func (s *AdminServer) handleRoomSwap(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/admin/api_rooms_bulk_delete.go b/internal/admin/api_rooms_bulk_delete.go
index 6aa0181..18b10a5 100644
--- a/internal/admin/api_rooms_bulk_delete.go
+++ b/internal/admin/api_rooms_bulk_delete.go
@@ -72,6 +72,9 @@ func (s *AdminServer) handleBulkDelete(w http.ResponseWriter, r *http.Request) {
s.world.RebuildRoomIndex(s.dataDir)
for _, snap := range snapshots {
s.world.ClearRoomState(snap.id)
+ if s.evictRoom != nil {
+ s.evictRoom(snap.id)
+ }
}
allIDs, _ := listRoomIDs(s.dataDir)
diff --git a/internal/admin/server.go b/internal/admin/server.go
index ccad6a2..41c1e0f 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -58,13 +58,18 @@ type AdminServer struct {
// configPath is the on-disk config.yaml path, used by the color themer
// to persist edited default_colors back to the file the game loaded.
configPath string
+ // evictRoom evicts cached in-room games (tables/machines) when a room
+ // is deleted or renumbered: outstanding wagers are refunded through the
+ // wired game callback before the cache entries drop.
+ evictRoom func(roomID int)
+
// rebuildCraftIndex rebuilds the in-game craft index after item files
// change (create/update/delete/rename/move/undo/redo). Wired from the
// Game so the web editor's item edits stay consistent with gameplay.
rebuildCraftIndex func()
}
-func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), rebuildCraftIndex func(), configPath string) (*AdminServer, error) {
+func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), evictRoom func(roomID int), rebuildCraftIndex func(), configPath string) (*AdminServer, error) {
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("cookie secret: %w", err)
@@ -109,9 +114,13 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
cookieSecret: secret,
reloadCourses: reloadCourses,
rewriteRoomIDs: rewriteRoomIDs,
+ evictRoom: evictRoom,
rebuildCraftIndex: rebuildCraftIndex,
configPath: configPath,
}
+ // Undo/redo of room-ID changes applies the (inverse) runtime migration
+ // through the same wired callback the rename uses.
+ s.undoStack.SetMigrateRoomIDs(rewriteRoomIDs)
mux := http.NewServeMux()
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index 5e1e6a3..0f9744c 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -29,6 +29,11 @@ type ChangeDesc struct {
IsDelete bool `json:"is_delete"`
IsCreate bool `json:"is_create"`
ExtraFiles []ExtraFile `json:"extra_files,omitempty"`
+ // IDMap records a room-ID change (oldID -> newID) included in the
+ // change. Undo applies its inverse, redo the forward map, through the
+ // wired runtime migration so players, Hub membership, and casino room
+ // keys move back and forth with the file images.
+ IDMap map[int]int `json:"id_map,omitempty"`
}
func (c ChangeDesc) ShortDesc() string {
@@ -63,6 +68,9 @@ type UndoStack struct {
redo []ChangeDesc
maxSize int
filePath string
+ // migrateRoomIDs applies a room-ID map to runtime state (players, Hub
+ // membership, casino room keys) during undo/redo of room-ID changes.
+ migrateRoomIDs func(map[int]int)
}
func NewUndoStack(dataDir string) *UndoStack {
@@ -73,6 +81,14 @@ func NewUndoStack(dataDir string) *UndoStack {
}
}
+// SetMigrateRoomIDs wires the runtime room-ID migration used by undo/redo
+// of room-ID changes. Called by the server after construction.
+func (us *UndoStack) SetMigrateRoomIDs(fn func(map[int]int)) {
+ us.mu.Lock()
+ defer us.mu.Unlock()
+ us.migrateRoomIDs = fn
+}
+
func (us *UndoStack) Push(desc ChangeDesc) {
us.mu.Lock()
defer us.mu.Unlock()
@@ -98,6 +114,16 @@ func (us *UndoStack) Undo() *ChangeDesc {
log.Printf("undo: failed %s: %v", last.ShortDesc(), err)
return nil
}
+ // Runtime migration moves with the file images: undo applies the
+ // inverse room-ID map so players, Hub membership, and casino room keys
+ // return to the old location together.
+ if len(last.IDMap) > 0 && us.migrateRoomIDs != nil {
+ inverse := make(map[int]int, len(last.IDMap))
+ for from, to := range last.IDMap {
+ inverse[to] = from
+ }
+ us.migrateRoomIDs(inverse)
+ }
us.history = us.history[:len(us.history)-1]
us.redo = append(us.redo, last)
@@ -118,6 +144,14 @@ func (us *UndoStack) Redo() *ChangeDesc {
log.Printf("redo: failed %s: %v", last.ShortDesc(), err)
return nil
}
+ // Redo applies the forward room-ID map through the runtime migration.
+ if len(last.IDMap) > 0 && us.migrateRoomIDs != nil {
+ forward := make(map[int]int, len(last.IDMap))
+ for from, to := range last.IDMap {
+ forward[from] = to
+ }
+ us.migrateRoomIDs(forward)
+ }
us.redo = us.redo[:len(us.redo)-1]
us.history = append(us.history, last)
@@ -126,64 +160,80 @@ func (us *UndoStack) Redo() *ChangeDesc {
return &last
}
+// applyUndo reverts a change: the primary file, then every extra file.
+// Extra-file failures propagate: a multi-file undo that restored only part
+// of a change must be reported as failed, never as success.
func (us *UndoStack) applyUndo(c ChangeDesc) error {
if c.NewFilePath != "" {
- os.Remove(c.NewFilePath)
+ if err := os.Remove(c.NewFilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove %s: %w", c.NewFilePath, err)
+ }
}
if c.IsDelete {
if c.OldContent == nil {
- os.MkdirAll(c.FilePath, 0755)
+ if err := os.MkdirAll(c.FilePath, 0755); err != nil {
+ return fmt.Errorf("recreate dir %s: %w", c.FilePath, err)
+ }
} else {
- os.MkdirAll(filepath.Dir(c.FilePath), 0755)
+ if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil {
+ return fmt.Errorf("mkdir: %w", err)
+ }
if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil {
return fmt.Errorf("restore deleted %s: %w", c.FilePath, err)
}
}
- for _, ef := range c.ExtraFiles {
- applyUndoExtraFile(ef)
- }
} else if c.IsCreate {
- os.Remove(c.FilePath)
- for _, ef := range c.ExtraFiles {
- applyUndoExtraFile(ef)
+ if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove created %s: %w", c.FilePath, err)
}
} else {
- target := c.FilePath
- content := c.OldContent
- if c.NewFilePath != "" {
- target = c.FilePath
+ if err := os.MkdirAll(filepath.Dir(c.FilePath), 0755); err != nil {
+ return fmt.Errorf("mkdir: %w", err)
}
- os.MkdirAll(filepath.Dir(target), 0755)
- if err := os.WriteFile(target, content, 0644); err != nil {
- return fmt.Errorf("revert %s: %w", target, err)
+ if err := os.WriteFile(c.FilePath, c.OldContent, 0644); err != nil {
+ return fmt.Errorf("revert %s: %w", c.FilePath, err)
}
- for _, ef := range c.ExtraFiles {
- applyUndoExtraFile(ef)
+ }
+ for _, ef := range c.ExtraFiles {
+ if err := applyUndoExtraFile(ef); err != nil {
+ return err
}
}
return nil
}
+// applyRedo re-applies a change: the primary file, then every extra file.
+// Extra-file failures propagate like undo.
func (us *UndoStack) applyRedo(c ChangeDesc) error {
if c.NewFilePath != "" {
- os.Remove(c.FilePath)
+ if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove %s: %w", c.FilePath, err)
+ }
}
if c.IsCreate {
target := c.FilePath
if c.NewFilePath != "" {
target = c.NewFilePath
}
- os.MkdirAll(filepath.Dir(target), 0755)
+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
+ return fmt.Errorf("mkdir: %w", err)
+ }
if err := os.WriteFile(target, c.NewContent, 0644); err != nil {
return fmt.Errorf("recreate %s: %w", target, err)
}
for _, ef := range c.ExtraFiles {
- applyRedoExtraFile(ef)
+ if err := applyRedoExtraFile(ef); err != nil {
+ return err
+ }
}
} else if c.IsDelete {
- os.Remove(c.FilePath)
+ if err := os.Remove(c.FilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("re-delete %s: %w", c.FilePath, err)
+ }
for _, ef := range c.ExtraFiles {
- applyRedoExtraFile(ef)
+ if err := applyRedoExtraFile(ef); err != nil {
+ return err
+ }
}
} else {
target := c.FilePath
@@ -191,49 +241,87 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
if c.NewFilePath != "" {
target = c.NewFilePath
}
- os.MkdirAll(filepath.Dir(target), 0755)
+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
+ return fmt.Errorf("mkdir: %w", err)
+ }
if err := os.WriteFile(target, content, 0644); err != nil {
return fmt.Errorf("reapply %s: %w", target, err)
}
for _, ef := range c.ExtraFiles {
- applyRedoExtraFile(ef)
+ if err := applyRedoExtraFile(ef); err != nil {
+ return err
+ }
}
}
return nil
}
-func applyUndoExtraFile(ef ExtraFile) {
+func applyUndoExtraFile(ef ExtraFile) error {
switch {
case ef.IsCreate:
- os.Remove(ef.FilePath)
+ if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("undo remove %s: %w", ef.FilePath, err)
+ }
case ef.IsDelete:
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
+ return fmt.Errorf("undo mkdir: %w", err)
+ }
+ if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil {
+ return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
+ }
case ef.NewFilePath != "":
- os.Remove(ef.NewFilePath)
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err)
+ }
+ if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
+ return fmt.Errorf("undo mkdir: %w", err)
+ }
+ if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil {
+ return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
+ }
default:
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.OldContent, 0644)
+ if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
+ return fmt.Errorf("undo mkdir: %w", err)
+ }
+ if err := os.WriteFile(ef.FilePath, ef.OldContent, 0644); err != nil {
+ return fmt.Errorf("undo revert %s: %w", ef.FilePath, err)
+ }
}
+ return nil
}
-func applyRedoExtraFile(ef ExtraFile) {
+func applyRedoExtraFile(ef ExtraFile) error {
switch {
case ef.IsDelete:
- os.Remove(ef.FilePath)
+ if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("redo remove %s: %w", ef.FilePath, err)
+ }
case ef.IsCreate:
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.NewContent, 0644)
+ if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
+ return fmt.Errorf("redo mkdir: %w", err)
+ }
+ if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil {
+ return fmt.Errorf("redo create %s: %w", ef.FilePath, err)
+ }
case ef.NewFilePath != "":
- os.Remove(ef.FilePath)
- os.MkdirAll(filepath.Dir(ef.NewFilePath), 0755)
- os.WriteFile(ef.NewFilePath, ef.NewContent, 0644)
+ if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("redo remove %s: %w", ef.FilePath, err)
+ }
+ if err := os.MkdirAll(filepath.Dir(ef.NewFilePath), 0755); err != nil {
+ return fmt.Errorf("redo mkdir: %w", err)
+ }
+ if err := os.WriteFile(ef.NewFilePath, ef.NewContent, 0644); err != nil {
+ return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err)
+ }
default:
- os.MkdirAll(filepath.Dir(ef.FilePath), 0755)
- os.WriteFile(ef.FilePath, ef.NewContent, 0644)
+ if err := os.MkdirAll(filepath.Dir(ef.FilePath), 0755); err != nil {
+ return fmt.Errorf("redo mkdir: %w", err)
+ }
+ if err := os.WriteFile(ef.FilePath, ef.NewContent, 0644); err != nil {
+ return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err)
+ }
}
+ return nil
}
func (us *UndoStack) Info() UndoInfo {
@@ -273,6 +361,7 @@ func (us *UndoStack) save() {
IsDelete bool `json:"is_delete"`
IsCreate bool `json:"is_create"`
ExtraFiles []extraEntry `json:"extra_files,omitempty"`
+ IDMap map[int]int `json:"id_map,omitempty"`
}
type saveData struct {
History []entry `json:"history"`
@@ -302,6 +391,7 @@ func (us *UndoStack) save() {
IsDelete: c.IsDelete,
IsCreate: c.IsCreate,
ExtraFiles: extra,
+ IDMap: c.IDMap,
})
}
return entries
@@ -344,6 +434,7 @@ func (us *UndoStack) load() {
IsDelete bool `json:"is_delete"`
IsCreate bool `json:"is_create"`
ExtraFiles []extraEntry `json:"extra_files,omitempty"`
+ IDMap map[int]int `json:"id_map,omitempty"`
}
var saveData struct {
History []entry `json:"history"`
@@ -377,6 +468,7 @@ func (us *UndoStack) load() {
IsDelete: e.IsDelete,
IsCreate: e.IsCreate,
ExtraFiles: extra,
+ IDMap: e.IDMap,
})
}
return changes
diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go
index 2d838ad..7157f15 100644
--- a/internal/admin/yaml_util.go
+++ b/internal/admin/yaml_util.go
@@ -17,15 +17,24 @@ func readYAMLFile(dataDir, subdir, id string) ([]byte, string, error) {
return data, path, err
}
-func writeYAMLFile(path string, data any) ([]byte, error) {
+func marshalYAML(data any) ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(data); err != nil {
return nil, err
}
- enc.Close()
- content := buf.Bytes()
+ if err := enc.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func writeYAMLFile(path string, data any) ([]byte, error) {
+ content, err := marshalYAML(data)
+ if err != nil {
+ return nil, err
+ }
if err := os.WriteFile(path, content, 0644); err != nil {
return nil, err
}