feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,221 @@
// Package operationlog implements the PostgreSQL-backed adapter for
// `ports.OperationLogStore`.
//
// The package owns the on-disk shape of the `operation_log` table
// defined in
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
// and translates the schema-agnostic `ports.OperationLogStore`
// interface declared in `internal/ports/operationlog.go` into
// concrete go-jet/v2 statements driven by the pgx driver.
//
// Append uses `INSERT ... RETURNING id` to surface the bigserial id
// back to callers; ListByGame is index-driven by
// `operation_log_game_started_idx`.
package operationlog
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed operation-log store.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Master operation-log entries in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed operation-log store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres operation log store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres operation log store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// operationLogSelectColumns matches scanRow's column order.
var operationLogSelectColumns = pg.ColumnList{
pgtable.OperationLog.ID,
pgtable.OperationLog.GameID,
pgtable.OperationLog.OpKind,
pgtable.OperationLog.OpSource,
pgtable.OperationLog.SourceRef,
pgtable.OperationLog.Outcome,
pgtable.OperationLog.ErrorCode,
pgtable.OperationLog.ErrorMessage,
pgtable.OperationLog.StartedAt,
pgtable.OperationLog.FinishedAt,
}
// Append inserts entry into the operation log and returns the
// generated bigserial id. entry is validated through
// operation.OperationEntry.Validate before the SQL is issued.
func (store *Store) Append(ctx context.Context, entry operation.OperationEntry) (int64, error) {
if store == nil || store.db == nil {
return 0, errors.New("append operation log entry: nil store")
}
if err := entry.Validate(); err != nil {
return 0, fmt.Errorf("append operation log entry: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "append operation log entry", store.operationTimeout)
if err != nil {
return 0, err
}
defer cancel()
stmt := pgtable.OperationLog.INSERT(
pgtable.OperationLog.GameID,
pgtable.OperationLog.OpKind,
pgtable.OperationLog.OpSource,
pgtable.OperationLog.SourceRef,
pgtable.OperationLog.Outcome,
pgtable.OperationLog.ErrorCode,
pgtable.OperationLog.ErrorMessage,
pgtable.OperationLog.StartedAt,
pgtable.OperationLog.FinishedAt,
).VALUES(
entry.GameID,
string(entry.OpKind),
string(entry.OpSource),
entry.SourceRef,
string(entry.Outcome),
entry.ErrorCode,
entry.ErrorMessage,
entry.StartedAt.UTC(),
sqlx.NullableTimePtr(entry.FinishedAt),
).RETURNING(pgtable.OperationLog.ID)
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
var id int64
if err := row.Scan(&id); err != nil {
return 0, fmt.Errorf("append operation log entry: %w", err)
}
return id, nil
}
// ListByGame returns the most recent entries for gameID, ordered by
// started_at descending and id descending (a tie-breaker that keeps
// the order stable when two rows share a started_at). The result is
// capped by limit; non-positive limit is rejected.
func (store *Store) ListByGame(ctx context.Context, gameID string, limit int) ([]operation.OperationEntry, error) {
if store == nil || store.db == nil {
return nil, errors.New("list operation log entries by game: nil store")
}
if strings.TrimSpace(gameID) == "" {
return nil, fmt.Errorf("list operation log entries by game: game id must not be empty")
}
if limit <= 0 {
return nil, fmt.Errorf("list operation log entries by game: limit must be positive, got %d", limit)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list operation log entries by game", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(operationLogSelectColumns).
FROM(pgtable.OperationLog).
WHERE(pgtable.OperationLog.GameID.EQ(pg.String(gameID))).
ORDER_BY(pgtable.OperationLog.StartedAt.DESC(), pgtable.OperationLog.ID.DESC()).
LIMIT(int64(limit))
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list operation log entries by game: %w", err)
}
defer rows.Close()
entries := make([]operation.OperationEntry, 0)
for rows.Next() {
got, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("list operation log entries by game: scan: %w", err)
}
entries = append(entries, got)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list operation log entries by game: %w", err)
}
if len(entries) == 0 {
return nil, nil
}
return entries, nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared
// across single-row and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRow scans one operation_log row from rs.
func scanRow(rs rowScanner) (operation.OperationEntry, error) {
var (
id int64
gameID string
opKind string
opSource string
sourceRef string
outcome string
errorCode string
errorMessage string
startedAt time.Time
finishedAt sql.NullTime
)
if err := rs.Scan(
&id,
&gameID,
&opKind,
&opSource,
&sourceRef,
&outcome,
&errorCode,
&errorMessage,
&startedAt,
&finishedAt,
); err != nil {
return operation.OperationEntry{}, err
}
return operation.OperationEntry{
ID: id,
GameID: gameID,
OpKind: operation.OpKind(opKind),
OpSource: operation.OpSource(opSource),
SourceRef: sourceRef,
Outcome: operation.Outcome(outcome),
ErrorCode: errorCode,
ErrorMessage: errorMessage,
StartedAt: startedAt.UTC(),
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
}, nil
}
// Ensure Store satisfies the ports.OperationLogStore interface at
// compile time.
var _ ports.OperationLogStore = (*Store)(nil)
@@ -0,0 +1,190 @@
package operationlog_test
import (
"context"
"testing"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
"galaxy/gamemaster/internal/adapters/postgres/operationlog"
"galaxy/gamemaster/internal/domain/operation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStore(t *testing.T) *operationlog.Store {
t.Helper()
pgtest.TruncateAll(t)
store, err := operationlog.New(operationlog.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return store
}
func successEntry(gameID string, kind operation.OpKind, source operation.OpSource, startedAt time.Time) operation.OperationEntry {
finishedAt := startedAt.Add(50 * time.Millisecond)
return operation.OperationEntry{
GameID: gameID,
OpKind: kind,
OpSource: source,
SourceRef: "req-001",
Outcome: operation.OutcomeSuccess,
StartedAt: startedAt,
FinishedAt: &finishedAt,
}
}
func TestNewRejectsInvalidConfig(t *testing.T) {
_, err := operationlog.New(operationlog.Config{})
require.Error(t, err)
store, err := operationlog.New(operationlog.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: 0,
})
require.Error(t, err)
require.Nil(t, store)
}
func TestAppendSuccessEntry(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
entry := successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at)
id, err := store.Append(ctx, entry)
require.NoError(t, err)
assert.Greater(t, id, int64(0))
entries, err := store.ListByGame(ctx, "game-001", 10)
require.NoError(t, err)
require.Len(t, entries, 1)
got := entries[0]
assert.Equal(t, id, got.ID)
assert.Equal(t, entry.GameID, got.GameID)
assert.Equal(t, entry.OpKind, got.OpKind)
assert.Equal(t, entry.OpSource, got.OpSource)
assert.Equal(t, entry.SourceRef, got.SourceRef)
assert.Equal(t, operation.OutcomeSuccess, got.Outcome)
assert.Empty(t, got.ErrorCode)
assert.Empty(t, got.ErrorMessage)
assert.True(t, got.StartedAt.Equal(at))
require.NotNil(t, got.FinishedAt)
assert.Equal(t, time.UTC, got.StartedAt.Location())
assert.Equal(t, time.UTC, got.FinishedAt.Location())
}
func TestAppendFailureEntry(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
finishedAt := at.Add(time.Second)
entry := operation.OperationEntry{
GameID: "game-001",
OpKind: operation.OpKindTurnGeneration,
OpSource: operation.OpSourceAdminRest,
Outcome: operation.OutcomeFailure,
ErrorCode: "engine_unreachable",
ErrorMessage: "connection refused",
StartedAt: at,
FinishedAt: &finishedAt,
}
_, err := store.Append(ctx, entry)
require.NoError(t, err)
got, err := store.ListByGame(ctx, "game-001", 1)
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, operation.OutcomeFailure, got[0].Outcome)
assert.Equal(t, "engine_unreachable", got[0].ErrorCode)
assert.Equal(t, "connection refused", got[0].ErrorMessage)
}
func TestAppendIDsAreMonotonic(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
id1, err := store.Append(ctx, successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at))
require.NoError(t, err)
id2, err := store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Second)))
require.NoError(t, err)
assert.Greater(t, id2, id1, "bigserial ids must be monotonic across appends")
}
func TestAppendValidationRejection(t *testing.T) {
ctx := context.Background()
store := newStore(t)
bad := operation.OperationEntry{}
_, err := store.Append(ctx, bad)
require.Error(t, err)
}
func TestListByGameOrderingDesc(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
_, err := store.Append(ctx, successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at))
require.NoError(t, err)
_, err = store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Second)))
require.NoError(t, err)
_, err = store.Append(ctx, successEntry("game-001", operation.OpKindStop, operation.OpSourceAdminRest, at.Add(2*time.Second)))
require.NoError(t, err)
got, err := store.ListByGame(ctx, "game-001", 10)
require.NoError(t, err)
require.Len(t, got, 3)
assert.Equal(t, operation.OpKindStop, got[0].OpKind)
assert.Equal(t, operation.OpKindTurnGeneration, got[1].OpKind)
assert.Equal(t, operation.OpKindRegisterRuntime, got[2].OpKind)
}
func TestListByGameRespectsLimit(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
for index := range 5 {
_, err := store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Duration(index)*time.Second)))
require.NoError(t, err)
}
got, err := store.ListByGame(ctx, "game-001", 2)
require.NoError(t, err)
require.Len(t, got, 2)
}
func TestListByGameUnknownGame(t *testing.T) {
ctx := context.Background()
store := newStore(t)
got, err := store.ListByGame(ctx, "unknown-game", 10)
require.NoError(t, err)
assert.Empty(t, got)
}
func TestListByGameRejectsBadArgs(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.ListByGame(ctx, "", 10)
require.Error(t, err)
_, err = store.ListByGame(ctx, "game-001", 0)
require.Error(t, err)
_, err = store.ListByGame(ctx, "game-001", -1)
require.Error(t, err)
}