From 5ad42a51df4dcc5ccc3aa6a6d902085ce2ee4239 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 18:08:01 -0400 Subject: fix: journal multi-file room/entity edits; simplify queued-position lookup --- internal/admin/api_rooms.go | 99 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 75 insertions(+), 24 deletions(-) (limited to 'internal/admin/api_rooms.go') diff --git a/internal/admin/api_rooms.go b/internal/admin/api_rooms.go index 7f317cc..015c0ee 100644 --- a/internal/admin/api_rooms.go +++ b/internal/admin/api_rooms.go @@ -802,8 +802,16 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { } } - var pushEntries []ChangeDesc - + // Resolve and serialize both sides before any write, then apply them + // in one journaled transaction (matching the link POST path): a + // failure mid-unlink must not leave an asymmetric link with no undo + // record to repair it. + type unlinkWrite struct { + desc ChangeDesc + path string + data []byte + } + var writes []unlinkWrite if roomA != nil && (body.Side == "" || body.Side == "from") { pathA, okA2 := s.world.GetRoomPath(body.From) if !okA2 { @@ -811,17 +819,17 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { return } oldA, _ := snapshotFile(pathA) - newA, err := writeYAMLFile(pathA, roomA) + newA, err := marshalYAML(roomA) if err != nil { - writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) + writeJSONError(w, "serialize A: "+err.Error(), http.StatusInternalServerError) return } - pushEntries = append(pushEntries, ChangeDesc{ + writes = append(writes, unlinkWrite{ChangeDesc{ Description: fmt.Sprintf("unlink %d %s %d", body.From, dirA, body.To), FilePath: pathA, OldContent: oldA, NewContent: newA, - }) + }, pathA, newA}) } if roomB != nil && (body.Side == "" || body.Side == "to") { @@ -831,21 +839,35 @@ func (s *AdminServer) handleRoomLink(w http.ResponseWriter, r *http.Request) { return } oldB, _ := snapshotFile(pathB) - newB, err := writeYAMLFile(pathB, roomB) + newB, err := marshalYAML(roomB) if err != nil { - writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) + writeJSONError(w, "serialize B: "+err.Error(), http.StatusInternalServerError) return } - pushEntries = append(pushEntries, ChangeDesc{ + writes = append(writes, unlinkWrite{ChangeDesc{ Description: fmt.Sprintf("unlink %d %s %d (reverse)", body.To, dirB, body.From), FilePath: pathB, OldContent: oldB, NewContent: newB, - }) + }, pathB, newB}) + } + + if len(writes) > 0 { + txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal")) + for _, wr := range writes { + if err := txn.Replace(wr.path, wr.data, 0644); err != nil { + writeJSONError(w, "plan unlink: "+err.Error(), http.StatusInternalServerError) + return + } + } + if err := txn.Commit(); err != nil { + writeJSONError(w, "commit unlink: "+err.Error(), http.StatusInternalServerError) + return + } } - for _, entry := range pushEntries { - s.undoStack.Push(entry) + for _, wr := range writes { + s.undoStack.Push(wr.desc) } writeJSON(w, map[string]any{"ok": true}) @@ -893,12 +915,15 @@ func (s *AdminServer) handleRoomMove(w http.ResponseWriter, r *http.Request) { failJSON(w, "read: "+err.Error()) return } - if err := os.WriteFile(newPath, data, 0644); err != nil { - failJSON(w, "write: "+err.Error()) + // Move the file in one journaled transaction: a failed destination write + // or source removal must not leave the same room ID in two directories. + txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal")) + if err := txn.Move(oldPath, newPath); err != nil { + failJSON(w, "plan move: "+err.Error()) return } - if err := os.Remove(oldPath); err != nil { - failJSON(w, "remove old: "+err.Error()) + if err := txn.Commit(); err != nil { + failJSON(w, "move: "+err.Error()) return } s.world.RebuildRoomIndex(s.dataDir) @@ -1175,15 +1200,30 @@ func (s *AdminServer) handleRoomSwap(w http.ResponseWriter, r *http.Request) { newB["id"] = idB newB["exits"] = exitsB - newAContent, err := writeMapAsYAML(aPath, newA) + newAContent, err := marshalMapYAML(newA) if err != nil { - writeJSONError(w, "write A: "+err.Error(), http.StatusInternalServerError) + writeJSONError(w, "serialize A: "+err.Error(), http.StatusInternalServerError) return } - newBContent, err := writeMapAsYAML(bPath, newB) + newBContent, err := marshalMapYAML(newB) if err != nil { - os.WriteFile(aPath, oldA, 0644) - writeJSONError(w, "write B: "+err.Error(), http.StatusInternalServerError) + writeJSONError(w, "serialize B: "+err.Error(), http.StatusInternalServerError) + return + } + + // Apply both files in one journaled transaction so a failure cannot leave + // the two rooms half-swapped. + txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal")) + if err := txn.Replace(aPath, newAContent, 0644); err != nil { + writeJSONError(w, "plan write A: "+err.Error(), http.StatusInternalServerError) + return + } + if err := txn.Replace(bPath, newBContent, 0644); err != nil { + writeJSONError(w, "plan write B: "+err.Error(), http.StatusInternalServerError) + return + } + if err := txn.Commit(); err != nil { + writeJSONError(w, "commit swap: "+err.Error(), http.StatusInternalServerError) return } @@ -1292,13 +1332,24 @@ func (s *AdminServer) handleResolveDuplicate(w http.ResponseWriter, r *http.Requ // field and filename change. m["id"] = body.NewID - newContent, err := writeMapAsYAML(newRel, m) + newContent, err := marshalMapYAML(m) if err != nil { failJSON(w, "write failed: "+err.Error()) return } - if err := os.Remove(absPath); err != nil { - failJSON(w, "remove old failed: "+err.Error()) + // Move + rewrite in one journaled transaction so a failure cannot + // leave the same ID in two directories. + txn := persistence.NewTransaction(filepath.Join(s.dataDir, ".journal")) + if err := txn.Move(absPath, newRel); err != nil { + failJSON(w, "plan rename failed: "+err.Error()) + return + } + if err := txn.Replace(newRel, newContent, 0644); err != nil { + failJSON(w, "plan write failed: "+err.Error()) + return + } + if err := txn.Commit(); err != nil { + failJSON(w, "rename failed: "+err.Error()) return } s.world.RebuildRoomIndex(s.dataDir) -- cgit v1.2.3