Repository: alicebob/miniredis

Issue:
Support for EVAL_RO and EVALSHA_RO
Noticed that the read only variants for the EVAL commands are not supported at the moment.
The logic for these commands could be mostly the same, but would have to error on calls to "write" functions.




Hints from issue comments:
@max-frank: I might be able to make a PR, but I have not fully understood the LUA implementation so can't make big promises atm.@max-frank: Current implementation plan

1. Introduce readonly marker for commands (#415)
2. Update `mkLua` 
    1. accept a readOnly flag indicating readonly mode
    2. add cmd readonly check to top off `call`/`pcall` and return readonly error in readonly mode
3. Update eval, evalsha and add RO variants. RO and write variants would call the same function with the only difference being the value of the readonly flag they pass in.@max-frank: 2 and 3 addressed in https://github.com/alicebob/miniredis/pull/416@alicebob: Hi! Thanks for the issue and the PR!, I'll have a look when I have some time.

Solution patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 7aea793..c0e95b6 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -9,7 +9,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        go: [ '1.17', '1.22', '1.23', '1.24' ]
+        go: [ '1.17', '1.23', '1.24', '1.25' ]
     name: Go ${{ matrix.go }}
     steps:
     - uses: actions/checkout@v4
diff --git a/cmd_generic.go b/cmd_generic.go
index 9d99a7c..2f55d5f 100644
--- a/cmd_generic.go
+++ b/cmd_generic.go
@@ -32,28 +32,28 @@ func inMilliSeconds(t time.Time) int {
 func commandsGeneric(m *Miniredis) {
 	m.srv.Register("COPY", m.cmdCopy)
 	m.srv.Register("DEL", m.cmdDel)
-	m.srv.Register("DUMP", m.cmdDump)
-	m.srv.Register("EXISTS", m.cmdExists)
+	m.srv.Register("DUMP", m.cmdDump, server.ReadOnlyOption())
+	m.srv.Register("EXISTS", m.cmdExists, server.ReadOnlyOption())
 	m.srv.Register("EXPIRE", makeCmdExpire(m, false, time.Second))
 	m.srv.Register("EXPIREAT", makeCmdExpire(m, true, time.Second))
-	m.srv.Register("EXPIRETIME", m.makeCmdExpireTime(inSeconds))
-	m.srv.Register("PEXPIRETIME", m.makeCmdExpireTime(inMilliSeconds))
-	m.srv.Register("KEYS", m.cmdKeys)
+	m.srv.Register("EXPIRETIME", m.makeCmdExpireTime(inSeconds), server.ReadOnlyOption())
+	m.srv.Register("PEXPIRETIME", m.makeCmdExpireTime(inMilliSeconds), server.ReadOnlyOption())
+	m.srv.Register("KEYS", m.cmdKeys, server.ReadOnlyOption())
 	// MIGRATE
 	m.srv.Register("MOVE", m.cmdMove)
 	// OBJECT
 	m.srv.Register("PERSIST", m.cmdPersist)
 	m.srv.Register("PEXPIRE", makeCmdExpire(m, false, time.Millisecond))
 	m.srv.Register("PEXPIREAT", makeCmdExpire(m, true, time.Millisecond))
-	m.srv.Register("PTTL", m.cmdPTTL)
-	m.srv.Register("RANDOMKEY", m.cmdRandomkey)
+	m.srv.Register("PTTL", m.cmdPTTL, server.ReadOnlyOption())
+	m.srv.Register("RANDOMKEY", m.cmdRandomkey, server.ReadOnlyOption())
 	m.srv.Register("RENAME", m.cmdRename)
 	m.srv.Register("RENAMENX", m.cmdRenamenx)
 	m.srv.Register("RESTORE", m.cmdRestore)
-	m.srv.Register("TOUCH", m.cmdTouch)
-	m.srv.Register("TTL", m.cmdTTL)
-	m.srv.Register("TYPE", m.cmdType)
-	m.srv.Register("SCAN", m.cmdScan)
+	m.srv.Register("TOUCH", m.cmdTouch, server.ReadOnlyOption())
+	m.srv.Register("TTL", m.cmdTTL, server.ReadOnlyOption())
+	m.srv.Register("TYPE", m.cmdType, server.ReadOnlyOption())
+	m.srv.Register("SCAN", m.cmdScan, server.ReadOnlyOption())
 	// SORT
 	m.srv.Register("UNLINK", m.cmdDel)
 }
diff --git a/cmd_geo.go b/cmd_geo.go
index 4d35a75..12cf99a 100644
--- a/cmd_geo.go
+++ b/cmd_geo.go
@@ -14,12 +14,12 @@ import (
 // commandsGeo handles GEOADD, GEORADIUS etc.
 func commandsGeo(m *Miniredis) {
 	m.srv.Register("GEOADD", m.cmdGeoadd)
-	m.srv.Register("GEODIST", m.cmdGeodist)
-	m.srv.Register("GEOPOS", m.cmdGeopos)
+	m.srv.Register("GEODIST", m.cmdGeodist, server.ReadOnlyOption())
+	m.srv.Register("GEOPOS", m.cmdGeopos, server.ReadOnlyOption())
 	m.srv.Register("GEORADIUS", m.cmdGeoradius)
-	m.srv.Register("GEORADIUS_RO", m.cmdGeoradius)
+	m.srv.Register("GEORADIUS_RO", m.cmdGeoradius, server.ReadOnlyOption())
 	m.srv.Register("GEORADIUSBYMEMBER", m.cmdGeoradiusbymember)
-	m.srv.Register("GEORADIUSBYMEMBER_RO", m.cmdGeoradiusbymember)
+	m.srv.Register("GEORADIUSBYMEMBER_RO", m.cmdGeoradiusbymember, server.ReadOnlyOption())
 }
 
 // GEOADD
diff --git a/cmd_hash.go b/cmd_hash.go
index 0c30e2a..29fec82 100644
--- a/cmd_hash.go
+++ b/cmd_hash.go
@@ -13,21 +13,21 @@ import (
 // commandsHash handles all hash value operations.
 func commandsHash(m *Miniredis) {
 	m.srv.Register("HDEL", m.cmdHdel)
-	m.srv.Register("HEXISTS", m.cmdHexists)
-	m.srv.Register("HGET", m.cmdHget)
-	m.srv.Register("HGETALL", m.cmdHgetall)
+	m.srv.Register("HEXISTS", m.cmdHexists, server.ReadOnlyOption())
+	m.srv.Register("HGET", m.cmdHget, server.ReadOnlyOption())
+	m.srv.Register("HGETALL", m.cmdHgetall, server.ReadOnlyOption())
 	m.srv.Register("HINCRBY", m.cmdHincrby)
 	m.srv.Register("HINCRBYFLOAT", m.cmdHincrbyfloat)
-	m.srv.Register("HKEYS", m.cmdHkeys)
-	m.srv.Register("HLEN", m.cmdHlen)
-	m.srv.Register("HMGET", m.cmdHmget)
+	m.srv.Register("HKEYS", m.cmdHkeys, server.ReadOnlyOption())
+	m.srv.Register("HLEN", m.cmdHlen, server.ReadOnlyOption())
+	m.srv.Register("HMGET", m.cmdHmget, server.ReadOnlyOption())
 	m.srv.Register("HMSET", m.cmdHmset)
 	m.srv.Register("HSET", m.cmdHset)
 	m.srv.Register("HSETNX", m.cmdHsetnx)
-	m.srv.Register("HSTRLEN", m.cmdHstrlen)
-	m.srv.Register("HVALS", m.cmdHvals)
-	m.srv.Register("HSCAN", m.cmdHscan)
-	m.srv.Register("HRANDFIELD", m.cmdHrandfield)
+	m.srv.Register("HSTRLEN", m.cmdHstrlen, server.ReadOnlyOption())
+	m.srv.Register("HVALS", m.cmdHvals, server.ReadOnlyOption())
+	m.srv.Register("HSCAN", m.cmdHscan, server.ReadOnlyOption())
+	m.srv.Register("HRANDFIELD", m.cmdHrandfield, server.ReadOnlyOption())
 }
 
 // HSET
diff --git a/cmd_hll.go b/cmd_hll.go
index c7963fa..7bfc950 100644
--- a/cmd_hll.go
+++ b/cmd_hll.go
@@ -5,7 +5,7 @@ import "github.com/alicebob/miniredis/v2/server"
 // commandsHll handles all hll related operations.
 func commandsHll(m *Miniredis) {
 	m.srv.Register("PFADD", m.cmdPfadd)
-	m.srv.Register("PFCOUNT", m.cmdPfcount)
+	m.srv.Register("PFCOUNT", m.cmdPfcount, server.ReadOnlyOption())
 	m.srv.Register("PFMERGE", m.cmdPfmerge)
 }
 
diff --git a/cmd_list.go b/cmd_list.go
index c4f397c..9de1635 100644
--- a/cmd_list.go
+++ b/cmd_list.go
@@ -22,14 +22,14 @@ func commandsList(m *Miniredis) {
 	m.srv.Register("BLPOP", m.cmdBlpop)
 	m.srv.Register("BRPOP", m.cmdBrpop)
 	m.srv.Register("BRPOPLPUSH", m.cmdBrpoplpush)
-	m.srv.Register("LINDEX", m.cmdLindex)
-	m.srv.Register("LPOS", m.cmdLpos)
+	m.srv.Register("LINDEX", m.cmdLindex, server.ReadOnlyOption())
+	m.srv.Register("LPOS", m.cmdLpos, server.ReadOnlyOption())
 	m.srv.Register("LINSERT", m.cmdLinsert)
-	m.srv.Register("LLEN", m.cmdLlen)
+	m.srv.Register("LLEN", m.cmdLlen, server.ReadOnlyOption())
 	m.srv.Register("LPOP", m.cmdLpop)
 	m.srv.Register("LPUSH", m.cmdLpush)
 	m.srv.Register("LPUSHX", m.cmdLpushx)
-	m.srv.Register("LRANGE", m.cmdLrange)
+	m.srv.Register("LRANGE", m.cmdLrange, server.ReadOnlyOption())
 	m.srv.Register("LREM", m.cmdLrem)
 	m.srv.Register("LSET", m.cmdLset)
 	m.srv.Register("LTRIM", m.cmdLtrim)
diff --git a/cmd_server.go b/cmd_server.go
index cd74704..79e19fb 100644
--- a/cmd_server.go
+++ b/cmd_server.go
@@ -13,7 +13,7 @@ import (
 
 func commandsServer(m *Miniredis) {
 	m.srv.Register("COMMAND", m.cmdCommand)
-	m.srv.Register("DBSIZE", m.cmdDbsize)
+	m.srv.Register("DBSIZE", m.cmdDbsize, server.ReadOnlyOption())
 	m.srv.Register("FLUSHALL", m.cmdFlushall)
 	m.srv.Register("FLUSHDB", m.cmdFlushdb)
 	m.srv.Register("INFO", m.cmdInfo)
diff --git a/cmd_set.go b/cmd_set.go
index 7d01bb2..ec35d56 100644
--- a/cmd_set.go
+++ b/cmd_set.go
@@ -13,22 +13,22 @@ import (
 // commandsSet handles all set value operations.
 func commandsSet(m *Miniredis) {
 	m.srv.Register("SADD", m.cmdSadd)
-	m.srv.Register("SCARD", m.cmdScard)
-	m.srv.Register("SDIFF", m.cmdSdiff)
+	m.srv.Register("SCARD", m.cmdScard, server.ReadOnlyOption())
+	m.srv.Register("SDIFF", m.cmdSdiff, server.ReadOnlyOption())
 	m.srv.Register("SDIFFSTORE", m.cmdSdiffstore)
-	m.srv.Register("SINTERCARD", m.cmdSintercard)
-	m.srv.Register("SINTER", m.cmdSinter)
+	m.srv.Register("SINTERCARD", m.cmdSintercard, server.ReadOnlyOption())
+	m.srv.Register("SINTER", m.cmdSinter, server.ReadOnlyOption())
 	m.srv.Register("SINTERSTORE", m.cmdSinterstore)
-	m.srv.Register("SISMEMBER", m.cmdSismember)
-	m.srv.Register("SMEMBERS", m.cmdSmembers)
-	m.srv.Register("SMISMEMBER", m.cmdSmismember)
+	m.srv.Register("SISMEMBER", m.cmdSismember, server.ReadOnlyOption())
+	m.srv.Register("SMEMBERS", m.cmdSmembers, server.ReadOnlyOption())
+	m.srv.Register("SMISMEMBER", m.cmdSmismember, server.ReadOnlyOption())
 	m.srv.Register("SMOVE", m.cmdSmove)
 	m.srv.Register("SPOP", m.cmdSpop)
-	m.srv.Register("SRANDMEMBER", m.cmdSrandmember)
+	m.srv.Register("SRANDMEMBER", m.cmdSrandmember, server.ReadOnlyOption())
 	m.srv.Register("SREM", m.cmdSrem)
-	m.srv.Register("SUNION", m.cmdSunion)
+	m.srv.Register("SUNION", m.cmdSunion, server.ReadOnlyOption())
 	m.srv.Register("SUNIONSTORE", m.cmdSunionstore)
-	m.srv.Register("SSCAN", m.cmdSscan)
+	m.srv.Register("SSCAN", m.cmdSscan, server.ReadOnlyOption())
 }
 
 // SADD
diff --git a/cmd_sorted_set.go b/cmd_sorted_set.go
index 9838e39..1491a74 100644
--- a/cmd_sorted_set.go
+++ b/cmd_sorted_set.go
@@ -16,32 +16,32 @@ import (
 // commandsSortedSet handles all sorted set operations.
 func commandsSortedSet(m *Miniredis) {
 	m.srv.Register("ZADD", m.cmdZadd)
-	m.srv.Register("ZCARD", m.cmdZcard)
-	m.srv.Register("ZCOUNT", m.cmdZcount)
+	m.srv.Register("ZCARD", m.cmdZcard, server.ReadOnlyOption())
+	m.srv.Register("ZCOUNT", m.cmdZcount, server.ReadOnlyOption())
 	m.srv.Register("ZINCRBY", m.cmdZincrby)
-	m.srv.Register("ZINTER", m.makeCmdZinter(false))
+	m.srv.Register("ZINTER", m.makeCmdZinter(false), server.ReadOnlyOption())
 	m.srv.Register("ZINTERSTORE", m.makeCmdZinter(true))
-	m.srv.Register("ZLEXCOUNT", m.cmdZlexcount)
-	m.srv.Register("ZRANGE", m.cmdZrange)
-	m.srv.Register("ZRANGEBYLEX", m.makeCmdZrangebylex(false))
-	m.srv.Register("ZRANGEBYSCORE", m.makeCmdZrangebyscore(false))
-	m.srv.Register("ZRANK", m.makeCmdZrank(false))
+	m.srv.Register("ZLEXCOUNT", m.cmdZlexcount, server.ReadOnlyOption())
+	m.srv.Register("ZRANGE", m.cmdZrange, server.ReadOnlyOption())
+	m.srv.Register("ZRANGEBYLEX", m.makeCmdZrangebylex(false), server.ReadOnlyOption())
+	m.srv.Register("ZRANGEBYSCORE", m.makeCmdZrangebyscore(false), server.ReadOnlyOption())
+	m.srv.Register("ZRANK", m.makeCmdZrank(false), server.ReadOnlyOption())
 	m.srv.Register("ZREM", m.cmdZrem)
 	m.srv.Register("ZREMRANGEBYLEX", m.cmdZremrangebylex)
 	m.srv.Register("ZREMRANGEBYRANK", m.cmdZremrangebyrank)
 	m.srv.Register("ZREMRANGEBYSCORE", m.cmdZremrangebyscore)
-	m.srv.Register("ZREVRANGE", m.cmdZrevrange)
-	m.srv.Register("ZREVRANGEBYLEX", m.makeCmdZrangebylex(true))
-	m.srv.Register("ZREVRANGEBYSCORE", m.makeCmdZrangebyscore(true))
-	m.srv.Register("ZREVRANK", m.makeCmdZrank(true))
-	m.srv.Register("ZSCORE", m.cmdZscore)
-	m.srv.Register("ZMSCORE", m.cmdZMscore)
-	m.srv.Register("ZUNION", m.cmdZunion)
+	m.srv.Register("ZREVRANGE", m.cmdZrevrange, server.ReadOnlyOption())
+	m.srv.Register("ZREVRANGEBYLEX", m.makeCmdZrangebylex(true), server.ReadOnlyOption())
+	m.srv.Register("ZREVRANGEBYSCORE", m.makeCmdZrangebyscore(true), server.ReadOnlyOption())
+	m.srv.Register("ZREVRANK", m.makeCmdZrank(true), server.ReadOnlyOption())
+	m.srv.Register("ZSCORE", m.cmdZscore, server.ReadOnlyOption())
+	m.srv.Register("ZMSCORE", m.cmdZMscore, server.ReadOnlyOption())
+	m.srv.Register("ZUNION", m.cmdZunion, server.ReadOnlyOption())
 	m.srv.Register("ZUNIONSTORE", m.cmdZunionstore)
-	m.srv.Register("ZSCAN", m.cmdZscan)
+	m.srv.Register("ZSCAN", m.cmdZscan, server.ReadOnlyOption())
 	m.srv.Register("ZPOPMAX", m.cmdZpopmax(true))
 	m.srv.Register("ZPOPMIN", m.cmdZpopmax(false))
-	m.srv.Register("ZRANDMEMBER", m.cmdZrandmember)
+	m.srv.Register("ZRANDMEMBER", m.cmdZrandmember, server.ReadOnlyOption())
 }
 
 // ZADD
diff --git a/cmd_stream.go b/cmd_stream.go
index 0167eaf..aafc014 100644
--- a/cmd_stream.go
+++ b/cmd_stream.go
@@ -16,16 +16,16 @@ import (
 // commandsStream handles all stream operations.
 func commandsStream(m *Miniredis) {
 	m.srv.Register("XADD", m.cmdXadd)
-	m.srv.Register("XLEN", m.cmdXlen)
-	m.srv.Register("XREAD", m.cmdXread)
-	m.srv.Register("XRANGE", m.makeCmdXrange(false))
-	m.srv.Register("XREVRANGE", m.makeCmdXrange(true))
+	m.srv.Register("XLEN", m.cmdXlen, server.ReadOnlyOption())
+	m.srv.Register("XREAD", m.cmdXread, server.ReadOnlyOption())
+	m.srv.Register("XRANGE", m.makeCmdXrange(false), server.ReadOnlyOption())
+	m.srv.Register("XREVRANGE", m.makeCmdXrange(true), server.ReadOnlyOption())
 	m.srv.Register("XGROUP", m.cmdXgroup)
 	m.srv.Register("XINFO", m.cmdXinfo)
 	m.srv.Register("XREADGROUP", m.cmdXreadgroup)
 	m.srv.Register("XACK", m.cmdXack)
 	m.srv.Register("XDEL", m.cmdXdel)
-	m.srv.Register("XPENDING", m.cmdXpending)
+	m.srv.Register("XPENDING", m.cmdXpending, server.ReadOnlyOption())
 	m.srv.Register("XTRIM", m.cmdXtrim)
 	m.srv.Register("XAUTOCLAIM", m.cmdXautoclaim)
 	m.srv.Register("XCLAIM", m.cmdXclaim)
diff --git a/cmd_string.go b/cmd_string.go
index 9e9b859..19c3543 100644
--- a/cmd_string.go
+++ b/cmd_string.go
@@ -14,21 +14,21 @@ import (
 // commandsString handles all string value operations.
 func commandsString(m *Miniredis) {
 	m.srv.Register("APPEND", m.cmdAppend)
-	m.srv.Register("BITCOUNT", m.cmdBitcount)
+	m.srv.Register("BITCOUNT", m.cmdBitcount, server.ReadOnlyOption())
 	m.srv.Register("BITOP", m.cmdBitop)
-	m.srv.Register("BITPOS", m.cmdBitpos)
+	m.srv.Register("BITPOS", m.cmdBitpos, server.ReadOnlyOption())
 	m.srv.Register("DECRBY", m.cmdDecrby)
 	m.srv.Register("DECR", m.cmdDecr)
-	m.srv.Register("GETBIT", m.cmdGetbit)
-	m.srv.Register("GET", m.cmdGet)
+	m.srv.Register("GETBIT", m.cmdGetbit, server.ReadOnlyOption())
+	m.srv.Register("GET", m.cmdGet, server.ReadOnlyOption())
 	m.srv.Register("GETEX", m.cmdGetex)
-	m.srv.Register("GETRANGE", m.cmdGetrange)
+	m.srv.Register("GETRANGE", m.cmdGetrange, server.ReadOnlyOption())
 	m.srv.Register("GETSET", m.cmdGetset)
 	m.srv.Register("GETDEL", m.cmdGetdel)
 	m.srv.Register("INCRBYFLOAT", m.cmdIncrbyfloat)
 	m.srv.Register("INCRBY", m.cmdIncrby)
 	m.srv.Register("INCR", m.cmdIncr)
-	m.srv.Register("MGET", m.cmdMget)
+	m.srv.Register("MGET", m.cmdMget, server.ReadOnlyOption())
 	m.srv.Register("MSET", m.cmdMset)
 	m.srv.Register("MSETNX", m.cmdMsetnx)
 	m.srv.Register("PSETEX", m.cmdPsetex)
@@ -37,7 +37,7 @@ func commandsString(m *Miniredis) {
 	m.srv.Register("SET", m.cmdSet)
 	m.srv.Register("SETNX", m.cmdSetnx)
 	m.srv.Register("SETRANGE", m.cmdSetrange)
-	m.srv.Register("STRLEN", m.cmdStrlen)
+	m.srv.Register("STRLEN", m.cmdStrlen, server.ReadOnlyOption())
 }
 
 // SET
diff --git a/miniredis.go b/miniredis.go
index 72a145a..cc87474 100644
--- a/miniredis.go
+++ b/miniredis.go
@@ -373,6 +373,14 @@ func (m *Miniredis) Server() *server.Server {
 	return m.srv
 }
 
+// IsReadOnlyCommand checks if a command is marked as read-only
+func (m *Miniredis) IsReadOnlyCommand(cmd string) bool {
+	if m.srv == nil {
+		return false
+	}
+	return m.srv.IsReadOnlyCommand(cmd)
+}
+
 // Dump returns a text version of the selected DB, usable for debugging.
 //
 // Dump limits the maximum length of each key:value to "DumpMaxLineLen" characters.
diff --git a/server/cmdmeta.go b/server/cmdmeta.go
new file mode 100644
index 0000000..a1be7e1
--- /dev/null
+++ b/server/cmdmeta.go
@@ -0,0 +1,17 @@
+package server
+
+// cmdMeta holds metadata about a registered command
+type cmdMeta struct {
+	handler  Cmd
+	readOnly bool
+}
+
+// CmdOption is a function that configures command metadata
+type CmdOption func(*cmdMeta)
+
+// ReadOnlyOption marks a command as read-only
+func ReadOnlyOption() CmdOption {
+	return func(meta *cmdMeta) {
+		meta.readOnly = true
+	}
+}
diff --git a/server/server.go b/server/server.go
index b5f1b61..5bc2ce1 100644
--- a/server/server.go
+++ b/server/server.go
@@ -34,7 +34,7 @@ type Hook func(*Peer, string, ...string) bool
 // Server is a simple redis server
 type Server struct {
 	l         net.Listener
-	cmds      map[string]Cmd
+	cmds      map[string]*cmdMeta
 	preHook   Hook
 	peers     map[net.Conn]struct{}
 	mu        sync.Mutex
@@ -62,7 +62,7 @@ func NewServerTLS(addr string, cfg *tls.Config) (*Server, error) {
 
 func newServer(l net.Listener) *Server {
 	s := Server{
-		cmds:  map[string]Cmd{},
+		cmds:  map[string]*cmdMeta{},
 		peers: map[net.Conn]struct{}{},
 		l:     l,
 	}
@@ -143,14 +143,23 @@ func (s *Server) Close() {
 
 // Register a command. It can't have been registered before. Safe to call on a
 // running server.
-func (s *Server) Register(cmd string, f Cmd) error {
+func (s *Server) Register(cmd string, f Cmd, options ...CmdOption) error {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	cmd = strings.ToUpper(cmd)
 	if _, ok := s.cmds[cmd]; ok {
 		return fmt.Errorf("command already registered: %s", cmd)
 	}
-	s.cmds[cmd] = f
+
+	meta := &cmdMeta{
+		handler:  f,
+		readOnly: false,
+	}
+	for _, option := range options {
+		option(meta)
+	}
+	s.cmds[cmd] = meta
+
 	return nil
 }
 
@@ -205,7 +214,7 @@ func (s *Server) Dispatch(c *Peer, args []string) {
 	}
 
 	s.mu.Lock()
-	cb, ok := s.cmds[cmdUp]
+	cmdMeta, ok := s.cmds[cmdUp]
 	s.mu.Unlock()
 	if !ok {
 		c.WriteError(errUnknownCommand(cmd, args))
@@ -215,7 +224,7 @@ func (s *Server) Dispatch(c *Peer, args []string) {
 	s.mu.Lock()
 	s.infoCmds++
 	s.mu.Unlock()
-	cb(c, cmdUp, args)
+	cmdMeta.handler(c, cmdUp, args)
 	if c.SwitchResp3 != nil {
 		c.Resp3 = *c.SwitchResp3
 		c.SwitchResp3 = nil
@@ -229,6 +238,17 @@ func (s *Server) TotalCommands() int {
 	return s.infoCmds
 }
 
+// IsReadOnlyCommand checks if a command is marked as read-only
+func (s *Server) IsReadOnlyCommand(cmd string) bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	cmdUp := strings.ToUpper(cmd)
+	if cmdMeta, ok := s.cmds[cmdUp]; ok {
+		return cmdMeta.readOnly
+	}
+	return false
+}
+
 // ClientsLen gives the number of connected clients right now
 func (s *Server) ClientsLen() int {
 	s.mu.Lock()



Test patch:
diff --git a/server/server_test.go b/server/server_test.go
index 9ccbe82..ac861fc 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -271,3 +271,40 @@ func TestFormatFloat(t *testing.T) {
 	a += 1.2
 	eq(t, "4.8", a)
 }
+
+func TestReadOnlyOption(t *testing.T) {
+	srv, err := NewServer("127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("Failed to create server: %v", err)
+	}
+	defer srv.Close()
+
+	// Test handler function
+	testHandler := func(c *Peer, cmd string, args []string) {
+		c.WriteOK()
+	}
+
+	if err := srv.Register("TESTGET", testHandler, ReadOnlyOption()); err != nil {
+		t.Fatalf("Failed to register read-only command: %v", err)
+	}
+
+	if err := srv.Register("TESTSET", testHandler); err != nil {
+		t.Fatalf("Failed to register write command: %v", err)
+	}
+
+	if !srv.IsReadOnlyCommand("TESTGET") {
+		t.Error("TESTGET should be marked as read-only")
+	}
+
+	if srv.IsReadOnlyCommand("TESTSET") {
+		t.Error("TESTSET should not be marked as read-only")
+	}
+
+	if !srv.IsReadOnlyCommand("testget") {
+		t.Error("Command checking should be case insensitive")
+	}
+
+	if srv.IsReadOnlyCommand("NONEXISTENT") {
+		t.Error("Non-existent command should return false")
+	}
+}