diff options
| author | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-15 15:35:52 -0400 |
| commit | 0b0b25222b3131ada79ff78c28eb8bf07d977deb (patch) | |
| tree | 2ceaef5203595044f66a8f00064cd8e017f8f48d /internal/admin | |
| parent | 396c3facb8287b9a34076cae8f3d7b300d366d99 (diff) | |
| download | thehouseoficarus-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.go | 19 | ||||
| -rw-r--r-- | internal/admin/api_room_insert_remove.go | 5 | ||||
| -rw-r--r-- | internal/admin/api_room_rename_test.go | 149 | ||||
| -rw-r--r-- | internal/admin/api_rooms.go | 149 | ||||
| -rw-r--r-- | internal/admin/api_rooms_bulk_delete.go | 3 | ||||
| -rw-r--r-- | internal/admin/server.go | 11 | ||||
| -rw-r--r-- | internal/admin/undo.go | 178 | ||||
| -rw-r--r-- | internal/admin/yaml_util.go | 15 |
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 } |
