refactor: integrate chat persistence into Room
All checks were successful
CI / Deploy / test (pull_request) Successful in 14s
CI / Deploy / lint (pull_request) Successful in 25s
CI / Deploy / deploy (pull_request) Has been skipped

Move SaveMessage/LoadMessages logic into Room as private methods.
NewPersistentRoom auto-loads history and auto-saves on Send, removing
the need for handlers to coordinate persistence separately.
This commit is contained in:
Ryan Hamamura
2026-03-02 21:25:03 -10:00
parent 6d43bdea16
commit 2cfd42b606
4 changed files with 71 additions and 61 deletions

View File

@@ -3,11 +3,15 @@
package chat package chat
import ( import (
"context"
"encoding/json" "encoding/json"
"slices"
"sync" "sync"
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/ryanhamamura/games/db/repository"
) )
// Message is the wire format for chat messages over NATS. // Message is the wire format for chat messages over NATS.
@@ -21,26 +25,47 @@ type Message struct {
const maxMessages = 50 const maxMessages = 50
// Room manages an in-memory message buffer and NATS pub/sub for a single // Room manages an in-memory message buffer and NATS pub/sub for a single
// chat room (typically one per game). // chat room (typically one per game). When created with NewPersistentRoom,
// messages are automatically loaded from and saved to the database.
type Room struct { type Room struct {
subject string subject string
nc *nats.Conn nc *nats.Conn
messages []Message messages []Message
mu sync.Mutex mu sync.Mutex
// Optional persistence; nil for ephemeral rooms (e.g. snake).
queries *repository.Queries
roomID string
} }
// NewRoom creates a chat room that publishes and subscribes on the given // NewRoom creates an ephemeral chat room with no database persistence.
// NATS subject (e.g. "chat.abc123"). func NewRoom(nc *nats.Conn, subject string) *Room {
func NewRoom(nc *nats.Conn, subject string, initial []Message) *Room {
return &Room{ return &Room{
subject: subject, subject: subject,
nc: nc, nc: nc,
messages: initial,
} }
} }
// Send publishes a message to the room's NATS subject. // NewPersistentRoom creates a chat room backed by the database. It loads
// existing messages on creation and auto-saves new messages on Send.
func NewPersistentRoom(nc *nats.Conn, subject string, queries *repository.Queries, roomID string) *Room {
r := &Room{
subject: subject,
nc: nc,
queries: queries,
roomID: roomID,
}
r.messages = r.loadMessages()
return r
}
// Send publishes a message to the room's NATS subject and persists it
// if the room is backed by a database.
func (r *Room) Send(msg Message) { func (r *Room) Send(msg Message) {
if r.queries != nil {
r.saveMessage(msg)
}
data, err := json.Marshal(msg) data, err := json.Marshal(msg)
if err != nil { if err != nil {
log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message") log.Error().Err(err).Str("subject", r.subject).Msg("failed to marshal chat message")
@@ -90,3 +115,35 @@ func (r *Room) Subscribe() (chan *nats.Msg, *nats.Subscription, error) {
} }
return ch, sub, nil return ch, sub, nil
} }
func (r *Room) saveMessage(msg Message) {
err := r.queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
GameID: r.roomID,
Nickname: msg.Nickname,
Color: int64(msg.Slot),
Message: msg.Message,
CreatedAt: msg.Time,
})
if err != nil {
log.Error().Err(err).Str("room_id", r.roomID).Msg("failed to save chat message")
}
}
func (r *Room) loadMessages() []Message {
rows, err := r.queries.GetChatMessages(context.Background(), r.roomID)
if err != nil {
return nil
}
msgs := make([]Message, len(rows))
for i, row := range rows {
msgs[i] = Message{
Nickname: row.Nickname,
Slot: int(row.Color),
Message: row.Message,
Time: row.CreatedAt,
}
}
// DB returns newest-first; reverse for chronological display
slices.Reverse(msgs)
return msgs
}

View File

@@ -1,45 +0,0 @@
package chat
import (
"context"
"slices"
"github.com/ryanhamamura/games/db/repository"
"github.com/rs/zerolog/log"
)
// SaveMessage persists a chat message to the database.
func SaveMessage(queries *repository.Queries, roomID string, msg Message) {
err := queries.CreateChatMessage(context.Background(), repository.CreateChatMessageParams{
GameID: roomID,
Nickname: msg.Nickname,
Color: int64(msg.Slot),
Message: msg.Message,
CreatedAt: msg.Time,
})
if err != nil {
log.Error().Err(err).Str("room_id", roomID).Msg("failed to save chat message")
}
}
// LoadMessages loads persisted chat messages for a room, returning them
// in chronological order (oldest first).
func LoadMessages(queries *repository.Queries, roomID string) []Message {
rows, err := queries.GetChatMessages(context.Background(), roomID)
if err != nil {
return nil
}
msgs := make([]Message, len(rows))
for i, r := range rows {
msgs[i] = Message{
Nickname: r.Nickname,
Slot: int(r.Color),
Message: r.Message,
Time: r.CreatedAt,
}
}
// DB returns newest-first; reverse for chronological display
slices.Reverse(msgs)
return msgs
}

View File

@@ -85,9 +85,9 @@ func HandleGamePage(store *connect4.Store, sm *scs.SessionManager, queries *repo
} }
g := gi.GetGame() g := gi.GetGame()
msgs := chat.LoadMessages(queries, gameID) room := chat.NewPersistentRoom(nil, "", queries, gameID)
if err := pages.GamePage(g, myColor, msgs, c4ChatConfig(gameID)).Render(r.Context(), w); err != nil { if err := pages.GamePage(g, myColor, room.Messages(), c4ChatConfig(gameID)).Render(r.Context(), w); err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
} }
} }
@@ -111,7 +111,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
)) ))
chatCfg := c4ChatConfig(gameID) chatCfg := c4ChatConfig(gameID)
room := chat.NewRoom(nc, "connect4.chat."+gameID, chat.LoadMessages(queries, gameID)) room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
// Send initial render // Send initial render
sendGameComponents(sse, gi, myColor, room, chatCfg) sendGameComponents(sse, gi, myColor, room, chatCfg)
@@ -228,9 +228,7 @@ func HandleSendChat(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManager
Message: signals.ChatMsg, Message: signals.ChatMsg,
Time: time.Now().UnixMilli(), Time: time.Now().UnixMilli(),
} }
chat.SaveMessage(queries, gameID, msg) room := chat.NewPersistentRoom(nc, "connect4.chat."+gameID, queries, gameID)
room := chat.NewRoom(nc, "connect4.chat."+gameID, nil)
room.Send(msg) room.Send(msg)
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)

View File

@@ -126,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
var room *chat.Room var room *chat.Room
if sg.Mode == snake.ModeMultiplayer { if sg.Mode == snake.ModeMultiplayer {
room = chat.NewRoom(nc, "snake.chat."+gameID, nil) room = chat.NewRoom(nc, "snake.chat."+gameID)
chatCh, chatSub, err = room.Subscribe() chatCh, chatSub, err = room.Subscribe()
if err != nil { if err != nil {
return return
@@ -247,7 +247,7 @@ func HandleSendChat(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Session
Message: signals.ChatMsg, Message: signals.ChatMsg,
} }
room := chat.NewRoom(nc, "snake.chat."+gameID, nil) room := chat.NewRoom(nc, "snake.chat."+gameID)
room.Send(msg) room.Send(msg)
sse := datastar.NewSSE(w, r) sse := datastar.NewSSE(w, r)