// Package operationlogstore implements the PostgreSQL-backed adapter for // `ports.OperationLogStore`. // // The package owns the on-disk shape of the `operation_log` table defined // in // `galaxy/rtmanager/internal/adapters/postgres/migrations/00001_init.sql` // and translates the schema-agnostic `ports.OperationLogStore` interface // declared in `internal/ports/operationlogstore.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 operationlogstore import ( "context" "database/sql" "errors" "fmt" "strings" "time" "galaxy/rtmanager/internal/adapters/postgres/internal/sqlx" pgtable "galaxy/rtmanager/internal/adapters/postgres/jet/rtmanager/table" "galaxy/rtmanager/internal/domain/operation" "galaxy/rtmanager/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // Config configures one PostgreSQL-backed operation-log store instance. type Config struct { // DB stores the connection pool the store uses for every query. DB *sql.DB // OperationTimeout bounds one round trip. OperationTimeout time.Duration } // Store persists Runtime Manager 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 is the canonical SELECT list for the // operation_log table, matching scanEntry's column order. var operationLogSelectColumns = pg.ColumnList{ pgtable.OperationLog.ID, pgtable.OperationLog.GameID, pgtable.OperationLog.OpKind, pgtable.OperationLog.OpSource, pgtable.OperationLog.SourceRef, pgtable.OperationLog.ImageRef, pgtable.OperationLog.ContainerID, 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.ImageRef, pgtable.OperationLog.ContainerID, 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, entry.ImageRef, entry.ContainerID, 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 capped by limit. The (game_id, // started_at DESC) index drives the read. 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() { entry, err := scanEntry(rows) if err != nil { return nil, fmt.Errorf("list operation log entries by game: scan: %w", err) } entries = append(entries, entry) } 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 scanEntry can be shared // across both single-row reads and iterated reads. type rowScanner interface { Scan(dest ...any) error } // scanEntry scans one operation_log row from rs. func scanEntry(rs rowScanner) (operation.OperationEntry, error) { var ( id int64 gameID string opKind string opSource string sourceRef string imageRef string containerID string outcome string errorCode string errorMessage string startedAt time.Time finishedAt sql.NullTime ) if err := rs.Scan( &id, &gameID, &opKind, &opSource, &sourceRef, &imageRef, &containerID, &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, ImageRef: imageRef, ContainerID: containerID, 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)