// 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)