Files
galaxy-game/notification/internal/adapters/redisstate/codecs.go
T
2026-04-26 20:34:39 +02:00

106 lines
2.9 KiB
Go

package redisstate
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
)
// StreamOffset stores the persisted progress of the plain-XREAD intent
// consumer.
type StreamOffset struct {
// Stream stores the Redis Stream name.
Stream string
// LastProcessedEntryID stores the last durably processed Redis Stream
// entry identifier.
LastProcessedEntryID string
// UpdatedAt stores when the offset record was last updated.
UpdatedAt time.Time
}
// Validate reports whether offset contains a complete persisted consumer
// progress record.
func (offset StreamOffset) Validate() error {
if offset.Stream == "" {
return fmt.Errorf("stream offset stream must not be empty")
}
if offset.LastProcessedEntryID == "" {
return fmt.Errorf("stream offset last processed entry id must not be empty")
}
if offset.UpdatedAt.IsZero() {
return fmt.Errorf("stream offset updated at must not be zero")
}
if !offset.UpdatedAt.Equal(offset.UpdatedAt.UTC()) {
return fmt.Errorf("stream offset updated at must be UTC")
}
if !offset.UpdatedAt.Equal(offset.UpdatedAt.Truncate(time.Millisecond)) {
return fmt.Errorf("stream offset updated at must use millisecond precision")
}
return nil
}
type streamOffsetJSON struct {
Stream string `json:"stream"`
LastProcessedEntryID string `json:"last_processed_entry_id"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
// MarshalStreamOffset marshals one stream-offset record into the strict JSON
// representation owned by Notification Service.
func MarshalStreamOffset(offset StreamOffset) ([]byte, error) {
if err := offset.Validate(); err != nil {
return nil, fmt.Errorf("marshal stream offset: %w", err)
}
return marshalStrictJSON(streamOffsetJSON{
Stream: offset.Stream,
LastProcessedEntryID: offset.LastProcessedEntryID,
UpdatedAtMS: offset.UpdatedAt.UTC().UnixMilli(),
})
}
// UnmarshalStreamOffset unmarshals one strict JSON stream-offset record.
func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) {
var wire streamOffsetJSON
if err := unmarshalStrictJSON(payload, &wire); err != nil {
return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err)
}
offset := StreamOffset{
Stream: wire.Stream,
LastProcessedEntryID: wire.LastProcessedEntryID,
UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(),
}
if err := offset.Validate(); err != nil {
return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err)
}
return offset, nil
}
func marshalStrictJSON(value any) ([]byte, error) {
return json.Marshal(value)
}
func unmarshalStrictJSON(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewBuffer(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("unexpected trailing JSON input")
}
return err
}
return nil
}