hotkv 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +202 -0
  3. data/NOTICE +6 -0
  4. data/README.md +459 -0
  5. data/SECURITY.md +30 -0
  6. data/lib/hotkv/ai/call_result.rb +25 -0
  7. data/lib/hotkv/ai/prompt_cache.rb +135 -0
  8. data/lib/hotkv/ai/semantic_cache.rb +66 -0
  9. data/lib/hotkv/client.rb +201 -0
  10. data/lib/hotkv/cluster/crc16.rb +43 -0
  11. data/lib/hotkv/cluster.rb +184 -0
  12. data/lib/hotkv/commands/admin.rb +36 -0
  13. data/lib/hotkv/commands/all.rb +49 -0
  14. data/lib/hotkv/commands/bitmaps.rb +52 -0
  15. data/lib/hotkv/commands/geo.rb +146 -0
  16. data/lib/hotkv/commands/hashes.rb +169 -0
  17. data/lib/hotkv/commands/hyperloglog.rb +21 -0
  18. data/lib/hotkv/commands/keys.rb +170 -0
  19. data/lib/hotkv/commands/lists.rb +113 -0
  20. data/lib/hotkv/commands/probabilistic.rb +23 -0
  21. data/lib/hotkv/commands/publishing.rb +46 -0
  22. data/lib/hotkv/commands/scan.rb +82 -0
  23. data/lib/hotkv/commands/scripting.rb +128 -0
  24. data/lib/hotkv/commands/server.rb +175 -0
  25. data/lib/hotkv/commands/sets.rb +75 -0
  26. data/lib/hotkv/commands/sorted_sets.rb +258 -0
  27. data/lib/hotkv/commands/streams.rb +207 -0
  28. data/lib/hotkv/commands/strings.rb +178 -0
  29. data/lib/hotkv/commands/support.rb +69 -0
  30. data/lib/hotkv/connection.rb +221 -0
  31. data/lib/hotkv/errors.rb +145 -0
  32. data/lib/hotkv/extensions.rb +118 -0
  33. data/lib/hotkv/future.rb +55 -0
  34. data/lib/hotkv/llm_price.rb +53 -0
  35. data/lib/hotkv/namespaces/admin.rb +261 -0
  36. data/lib/hotkv/namespaces/amem.rb +172 -0
  37. data/lib/hotkv/namespaces/caching.rb +397 -0
  38. data/lib/hotkv/namespaces/core.rb +190 -0
  39. data/lib/hotkv/namespaces/ops.rb +166 -0
  40. data/lib/hotkv/namespaces/probabilistic.rb +180 -0
  41. data/lib/hotkv/namespaces/rag.rb +63 -0
  42. data/lib/hotkv/pipeline.rb +57 -0
  43. data/lib/hotkv/pool.rb +123 -0
  44. data/lib/hotkv/pubsub.rb +60 -0
  45. data/lib/hotkv/resp.rb +176 -0
  46. data/lib/hotkv/transaction.rb +115 -0
  47. data/lib/hotkv/url.rb +100 -0
  48. data/lib/hotkv/usage.rb +42 -0
  49. data/lib/hotkv/version.rb +7 -0
  50. data/lib/hotkv/wire.rb +145 -0
  51. data/lib/hotkv.rb +28 -0
  52. metadata +137 -0
data/lib/hotkv/resp.rb ADDED
@@ -0,0 +1,176 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ # A plain Ruby String subclass that remembers the three letter format tag
6
+ # ("txt" or "mkd") a RESP3 verbatim string (`=`) reply carried. Behaves as
7
+ # an ordinary String everywhere; only +format+ is extra.
8
+ class VerbatimString < String
9
+ attr_reader :format
10
+
11
+ def initialize(content, format)
12
+ super(content)
13
+ @format = format
14
+ end
15
+ end
16
+
17
+ # Low level RESP2/RESP3 request encoder and reply decoder. Every reply
18
+ # type in the protocol is handled: simple string, error, integer, bulk
19
+ # string, array, null, boolean, double, big number, blob error, verbatim
20
+ # string, map, set, attribute and push. Operates on any object that
21
+ # responds to #gets(separator) and #read(length) the way IO and StringIO
22
+ # do, so it needs no live socket to be unit tested.
23
+ module RESP
24
+ CRLF = "\r\n".b
25
+ private_constant :CRLF
26
+
27
+ module_function
28
+
29
+ # Encodes +args+ (Strings, Symbols, Integers, Floats, ...) as one RESP
30
+ # array of bulk strings, the wire form every RESP server
31
+ # expects a client request in, regardless of RESP2 or RESP3.
32
+ def encode_command(args)
33
+ out = String.new(+"*#{args.length}\r\n", encoding: Encoding::BINARY)
34
+ args.each do |a|
35
+ s = to_wire_string(a)
36
+ out << "$" << s.bytesize.to_s << CRLF << s << CRLF
37
+ end
38
+ out
39
+ end
40
+
41
+ # Strings go out byte for byte. Integers use their decimal text. Every
42
+ # other number (Float, BigDecimal, Rational) is written by Wire.float:
43
+ # plain decimal, never an exponent such as "1.0e-05", which the server's
44
+ # stricter numeric parsers refuse.
45
+ def to_wire_string(arg)
46
+ case arg
47
+ when String then arg.b
48
+ when Integer then arg.to_s.b
49
+ when Numeric then Wire.float(arg).b
50
+ else arg.to_s.b
51
+ end
52
+ end
53
+
54
+ # Reads exactly one reply from +io+.
55
+ #
56
+ # +raise_on_error+ governs only the value this call itself returns: when
57
+ # true (the default) a top level error reply is raised as a
58
+ # HotKV::Error::ServerError subclass; when false it is returned as that
59
+ # exception instance instead. Either way, an error found while decoding
60
+ # an element nested inside an array/map/set/push is always returned
61
+ # inline as the exception instance (never raised), so one failed command
62
+ # inside a MULTI/EXEC or pipeline reply never discards the rest of it.
63
+ def read_reply(io, raise_on_error: true)
64
+ value = read_value(io)
65
+ raise value if raise_on_error && value.is_a?(Error::ServerError)
66
+
67
+ value
68
+ end
69
+
70
+ def read_value(io)
71
+ line = read_line(io)
72
+ type = line.getbyte(0)
73
+ raise Error::ProtocolError, "malformed RESP reply: empty line where a type byte was expected" if type.nil?
74
+
75
+ body = line.byteslice(1..-1)
76
+ begin
77
+ decode(type, body, io)
78
+ rescue ArgumentError, RangeError => e
79
+ raise Error::ProtocolError, "malformed RESP reply near #{line.inspect}: #{e.message}"
80
+ end
81
+ end
82
+
83
+ def decode(type, body, io)
84
+ case type
85
+ when 43 then body # '+' simple string
86
+ when 45 then Error::ServerError.from_message(body) # '-' error
87
+ when 58 then Integer(body) # ':' integer
88
+ when 36 then read_bulk(io, body) # '$' bulk string
89
+ when 42 then read_array(io, body) # '*' array
90
+ when 95 then nil # '_' null
91
+ when 35 then body == "t" # '#' boolean
92
+ when 44 then read_double(body) # ',' double
93
+ when 40 then Integer(body) # '(' big number
94
+ when 33 then Error::ServerError.from_message(read_bulk(io, body)) # '!' blob error
95
+ when 61 then read_verbatim(io, body) # '=' verbatim string
96
+ when 37 then read_map(io, body) # '%' map
97
+ when 126 then read_multi(io, body) # '~' set
98
+ when 62 then read_multi(io, body) # '>' push
99
+ when 124 then read_attribute(io, body) # '|' attribute (transparent)
100
+ else
101
+ raise Error::ProtocolError, "unknown RESP type byte #{type.chr.inspect}"
102
+ end
103
+ end
104
+
105
+ def read_line(io)
106
+ line = io.gets(CRLF)
107
+ raise Error::ConnectionError, "connection closed by server" if line.nil?
108
+ raise Error::ConnectionError, "connection closed by server (truncated reply line)" unless line.chomp!(CRLF)
109
+
110
+ line
111
+ end
112
+
113
+ def read_exact(io, n)
114
+ return "".b if n.zero?
115
+
116
+ data = io.read(n)
117
+ raise Error::ConnectionError, "connection closed by server (expected #{n} bytes)" if data.nil? || data.bytesize != n
118
+
119
+ data
120
+ end
121
+
122
+ def read_bulk(io, len_str)
123
+ len = Integer(len_str)
124
+ return nil if len.negative?
125
+
126
+ data = read_exact(io, len)
127
+ terminator = read_exact(io, 2)
128
+ raise Error::ProtocolError, "malformed bulk string terminator" unless terminator == CRLF
129
+
130
+ data
131
+ end
132
+
133
+ def read_verbatim(io, len_str)
134
+ payload = read_bulk(io, len_str)
135
+ return nil if payload.nil?
136
+
137
+ VerbatimString.new(payload.byteslice(4..-1) || "".b, payload.byteslice(0, 3))
138
+ end
139
+
140
+ def read_double(body)
141
+ case body
142
+ when "inf" then Float::INFINITY
143
+ when "-inf" then -Float::INFINITY
144
+ when "nan" then Float::NAN
145
+ else Float(body)
146
+ end
147
+ end
148
+
149
+ def read_array(io, len_str)
150
+ len = Integer(len_str)
151
+ return nil if len.negative? # RESP2 null array
152
+
153
+ Array.new(len) { read_value(io) }
154
+ end
155
+
156
+ def read_multi(io, len_str)
157
+ Array.new(Integer(len_str)) { read_value(io) }
158
+ end
159
+
160
+ def read_map(io, len_str)
161
+ count = Integer(len_str)
162
+ h = {}
163
+ count.times { h[read_value(io)] = read_value(io) }
164
+ h
165
+ end
166
+
167
+ def read_attribute(io, len_str)
168
+ count = Integer(len_str)
169
+ count.times do
170
+ read_value(io)
171
+ read_value(io)
172
+ end
173
+ read_value(io) # the attribute precedes and annotates the value that follows it
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,115 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ # Server side MULTI/EXEC transaction. Every command queued inside the
6
+ # block given to HotKV::Client#multi (or HotKV::WatchSession#multi) is
7
+ # sent to the server immediately and queued there; #exec runs EXEC and
8
+ # resolves every queued command's Future from the resulting array.
9
+ #
10
+ # result = client.multi do |tx|
11
+ # tx.set("a", "1")
12
+ # tx.incr("a")
13
+ # end
14
+ # result # => ["OK", 2], or nil if a watched key changed first
15
+ class Transaction
16
+ include Commands::All
17
+
18
+ def initialize(connection)
19
+ @connection = connection
20
+ @queue = []
21
+ end
22
+
23
+ def command(*args, &transform)
24
+ invoke(*args, &transform)
25
+ end
26
+ alias_method :call, :command
27
+
28
+ # Sends +args+ to the server right away (MULTI is already active), so a
29
+ # bad command is caught as soon as it is queued, exactly like the
30
+ # server itself does.
31
+ def invoke(*args, &transform)
32
+ @connection.write_command(args)
33
+ reply = @connection.read_reply(raise_on_error: false)
34
+ future = Future.new(&transform)
35
+ if reply.is_a?(Error::ServerError)
36
+ future.resolve_error(reply)
37
+ @queue << [true, future]
38
+ else
39
+ @queue << [false, future]
40
+ end
41
+ future
42
+ end
43
+
44
+ def size
45
+ @queue.size
46
+ end
47
+
48
+ # Runs EXEC and resolves every queued Future. Returns a plain Array of
49
+ # resolved values (or per command Error instances) in queue order, or
50
+ # nil if the transaction was aborted because a watched key changed.
51
+ def exec
52
+ @connection.write_command(["EXEC"])
53
+ raw = @connection.read_reply(raise_on_error: false)
54
+ return nil if raw.nil?
55
+ raise raw if raw.is_a?(Error::ServerError)
56
+
57
+ idx = 0
58
+ @queue.each do |(already_resolved, future)|
59
+ next if already_resolved
60
+
61
+ future.resolve(raw[idx])
62
+ idx += 1
63
+ end
64
+ @queue.map { |(_resolved, future)| future.value }
65
+ end
66
+ end
67
+
68
+ # Yielded by HotKV::Client#watch. Bound to a single connection for the
69
+ # life of the watch block so that reads used to decide whether to
70
+ # transact, and the eventual transaction itself, see a consistent view
71
+ # and share the same WATCH state. Supports every typed command method
72
+ # (run immediately against the watched connection) plus #multi to start
73
+ # the actual MULTI/EXEC transaction.
74
+ class WatchSession
75
+ include Commands::All
76
+
77
+ def initialize(connection)
78
+ @connection = connection
79
+ @multi_started = false
80
+ end
81
+
82
+ def multi_started?
83
+ @multi_started
84
+ end
85
+
86
+ def invoke(*args)
87
+ raw = @connection.call(*args, raise_on_error: false)
88
+ raise raw if raw.is_a?(Error::ServerError)
89
+
90
+ block_given? ? yield(raw) : raw
91
+ end
92
+
93
+ def command(*args)
94
+ invoke(*args) { |raw| raw }
95
+ end
96
+ alias_method :call, :command
97
+
98
+ # Starts MULTI on the watched connection, yields a Transaction to queue
99
+ # commands on, and runs EXEC. Returns whatever Transaction#exec returns.
100
+ def multi
101
+ raise Error::ArgumentError, "multi requires a block" unless block_given?
102
+
103
+ @connection.call("MULTI")
104
+ @multi_started = true
105
+ tx = Transaction.new(@connection)
106
+ begin
107
+ yield tx
108
+ rescue StandardError
109
+ @connection.call("DISCARD", raise_on_error: false)
110
+ raise
111
+ end
112
+ tx.exec
113
+ end
114
+ end
115
+ end
data/lib/hotkv/url.rb ADDED
@@ -0,0 +1,100 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ require "uri"
5
+
6
+ module HotKV
7
+ # Parses HotKV connection URLs into the option hash HotKV::Client#initialize
8
+ # understands. Accepts the native `hotkv://` / `hotkvs://` (TLS) schemes and,
9
+ # for drop in migration from other clients, `redis://` / `rediss://` and the
10
+ # unix socket forms `hotkv+unix://` / `redis+unix://`.
11
+ module URL
12
+ TLS_SCHEMES = %w[hotkvs rediss].freeze
13
+ PLAIN_SCHEMES = %w[hotkv redis].freeze
14
+ UNIX_SCHEMES = %w[hotkv+unix redis+unix].freeze
15
+ DEFAULT_PORT = 6379
16
+
17
+ module_function
18
+
19
+ # Returns a Hash of connection options parsed out of +url+, using only
20
+ # the keys that were present in the URL. HotKV::Client merges this under
21
+ # its explicit keyword options, so an explicit keyword always wins.
22
+ def parse(url)
23
+ raise Error::ArgumentError, "url must be a String, got #{url.class}" unless url.is_a?(String)
24
+
25
+ uri = begin
26
+ URI.parse(url)
27
+ rescue URI::InvalidURIError => e
28
+ raise Error::ArgumentError, "invalid HotKV URL #{url.inspect}: #{e.message}"
29
+ end
30
+ scheme = uri.scheme&.downcase
31
+ opts = {}
32
+
33
+ if UNIX_SCHEMES.include?(scheme)
34
+ path = uri.path
35
+ path = uri.opaque if path.nil? || path.empty?
36
+ raise Error::ArgumentError, "#{scheme}:// URL must include a socket path" if path.nil? || path.empty?
37
+
38
+ opts[:path] = path
39
+ elsif TLS_SCHEMES.include?(scheme) || PLAIN_SCHEMES.include?(scheme)
40
+ opts[:ssl] = TLS_SCHEMES.include?(scheme)
41
+ opts[:host] = (uri.host && !uri.host.empty?) ? uri.host : "127.0.0.1"
42
+ opts[:port] = uri.port || DEFAULT_PORT
43
+ else
44
+ raise Error::ArgumentError,
45
+ "unsupported HotKV URL scheme #{uri.scheme.inspect}, expected hotkv://, hotkvs://, " \
46
+ "redis://, rediss:// or a +unix variant"
47
+ end
48
+
49
+ apply_userinfo!(opts, uri.userinfo)
50
+ apply_path_db!(opts, uri.path) unless UNIX_SCHEMES.include?(scheme)
51
+ apply_query!(opts, uri.query)
52
+ opts
53
+ end
54
+
55
+ def apply_userinfo!(opts, userinfo)
56
+ return unless userinfo
57
+
58
+ if userinfo.include?(":")
59
+ user, pass = userinfo.split(":", 2)
60
+ else
61
+ user, pass = userinfo, nil
62
+ end
63
+ opts[:username] = URI.decode_www_form_component(user) if user && !user.empty?
64
+ opts[:password] = URI.decode_www_form_component(pass) if pass
65
+ end
66
+ private_class_method :apply_userinfo!
67
+
68
+ def apply_path_db!(opts, path)
69
+ return if path.nil?
70
+
71
+ db = path.sub(%r{\A/}, "")
72
+ opts[:db] = Integer(db) unless db.empty?
73
+ rescue ArgumentError
74
+ raise Error::ArgumentError, "invalid database index in URL path #{path.inspect}"
75
+ end
76
+ private_class_method :apply_path_db!
77
+
78
+ QUERY_BOOLEANS = %w[insecure_skip_verify skip_verify].freeze
79
+ private_constant :QUERY_BOOLEANS
80
+
81
+ def apply_query!(opts, query)
82
+ return if query.nil? || query.empty?
83
+
84
+ URI.decode_www_form(query).each do |key, value|
85
+ case key
86
+ when "db", "database" then opts[:db] = Integer(value)
87
+ when "client_name", "clientname" then opts[:client_name] = value
88
+ when "connect_timeout" then opts[:connect_timeout] = Float(value)
89
+ when "timeout", "command_timeout", "read_timeout" then opts[:command_timeout] = Float(value)
90
+ when "ca_file", "sslcacert" then opts[:ca_file] = value
91
+ when "cert_file", "sslcert" then opts[:cert_file] = value
92
+ when "key_file", "sslkey" then opts[:key_file] = value
93
+ when "server_name", "sni" then opts[:server_name] = value
94
+ when *QUERY_BOOLEANS then opts[:insecure_skip_verify] = %w[1 true yes].include?(value.to_s.downcase)
95
+ end
96
+ end
97
+ end
98
+ private_class_method :apply_query!
99
+ end
100
+ end
@@ -0,0 +1,42 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ # One model call's token usage: the token usage group shared by
6
+ # PCACHE.SET, SCACHE.SET, every keyed cache SET (TOOL.CACHE/NCACHE/
7
+ # GUARD/RERANK) and LLMBUDGET.CONSUME.
8
+ #
9
+ # Recording a TokenUsage with a cache entry lets a later cache hit
10
+ # attribute its tokens, and once the model is priced its exact cost, to
11
+ # the namespace's usage_saved_* stats fields.
12
+ #
13
+ # +model+ is required; every count defaults to 0 and is left off the
14
+ # wire when zero (MODEL itself is always sent once a TokenUsage is
15
+ # given):
16
+ #
17
+ # usage = HotKV::TokenUsage.new(model: "claude-sonnet-5", input_tokens: 120, output_tokens: 40)
18
+ # client.pcache.set(ns, prompt, response, usage: usage)
19
+ TokenUsage = Struct.new(
20
+ :model, :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_5m_tokens,
21
+ :cache_write_1h_tokens,
22
+ keyword_init: true
23
+ ) do
24
+ def initialize(model:, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0,
25
+ cache_write_5m_tokens: 0, cache_write_1h_tokens: 0)
26
+ super
27
+ end
28
+
29
+ # The `MODEL <model> [INPUT n] [OUTPUT n] [CACHEREAD n] [CACHEWRITE5M
30
+ # n] [CACHEWRITE1H n]` KEY VALUE pairs this usage adds to a command's
31
+ # trailing options.
32
+ def wire_args
33
+ args = ["MODEL", model]
34
+ args.push("INPUT", input_tokens) unless input_tokens.zero?
35
+ args.push("OUTPUT", output_tokens) unless output_tokens.zero?
36
+ args.push("CACHEREAD", cache_read_tokens) unless cache_read_tokens.zero?
37
+ args.push("CACHEWRITE5M", cache_write_5m_tokens) unless cache_write_5m_tokens.zero?
38
+ args.push("CACHEWRITE1H", cache_write_1h_tokens) unless cache_write_1h_tokens.zero?
39
+ args
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,7 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ # Current release version of the hotkv gem.
6
+ VERSION = "1.0.0"
7
+ end
data/lib/hotkv/wire.rb ADDED
@@ -0,0 +1,145 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ module HotKV
5
+ # Formatting of numeric command arguments, in one place.
6
+ #
7
+ # Ruby's Float#to_s switches to exponent form for small and large values
8
+ # ("1.0e-05", "1.0e+16"). Several server parsers refuse that form: the
9
+ # fixed point USD parser behind LLMBUDGET.SET and LLMSTATS.PRICE SET accepts
10
+ # plain digits only, with no exponent. Every Float, BigDecimal or Rational a
11
+ # caller hands to a command is therefore written by #float: plain decimal,
12
+ # the shortest digits that round trip, never an exponent, never locale
13
+ # dependent (no String#% or Kernel#format involved).
14
+ module Wire
15
+ module_function
16
+
17
+ FLOAT_PARTS = /\A(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?\z/.freeze
18
+ private_constant :FLOAT_PARTS
19
+
20
+ # Plain decimal text for +value+ (Integer, Float, BigDecimal, Rational).
21
+ # Infinity becomes "inf"/"-inf" (what the server's score parsers accept);
22
+ # NaN raises HotKV::Error::ArgumentError.
23
+ def float(value)
24
+ case value
25
+ when Integer then value.to_s
26
+ when Float then float_text(value)
27
+ when Rational then float_text(value.to_f)
28
+ when Numeric
29
+ raise Error::ArgumentError, "cannot format #{value.class} as a number" if value.respond_to?(:real?) && !value.real?
30
+
31
+ decimal_text(value)
32
+ else
33
+ raise Error::ArgumentError, "expected a number, got #{value.inspect}"
34
+ end
35
+ end
36
+
37
+ # Parses a float out of a server reply. Numbers pass through; Strings
38
+ # follow strtod, which is what the server prints with: "inf", "+inf",
39
+ # "-inf", "infinity" and "nan" (any case) as well as ordinary decimals and
40
+ # exponent forms. Kernel#Float alone refuses "inf", which a RESP2 reply
41
+ # gives for an infinite sorted set score.
42
+ def parse_float(value)
43
+ return value.to_f if value.is_a?(Numeric)
44
+ raise TypeError, "can't convert #{value.class} into Float" unless value.is_a?(String)
45
+
46
+ case value.downcase
47
+ when "inf", "+inf", "infinity", "+infinity" then Float::INFINITY
48
+ when "-inf", "-infinity" then -Float::INFINITY
49
+ when "nan", "+nan", "-nan" then Float::NAN
50
+ else Float(value)
51
+ end
52
+ end
53
+
54
+ # Returns +value+ unchanged after refusing NaN and +/-Infinity, for the
55
+ # arguments where the server accepts finite numbers only (INCRBYFLOAT,
56
+ # coordinates, similarity thresholds, vector components, ...). Non numeric
57
+ # values (Strings, nil) are passed through for the server to judge.
58
+ def finite(value)
59
+ if value.is_a?(Numeric) && !value.is_a?(Integer) && !value.is_a?(Rational)
60
+ raise Error::ArgumentError, "expected a finite number, got #{value}" if value.nan? || value.infinite?
61
+ end
62
+ value
63
+ end
64
+
65
+ # Applies #finite to every component of +vector+ and returns an Array.
66
+ def finite_vector(vector)
67
+ Array(vector).map { |component| finite(component) }
68
+ end
69
+
70
+ # Comma joined plain decimal components, the form RAG.INGEST VECTORS and
71
+ # RAG.SEARCH VECTOR take.
72
+ def csv_vector(vector)
73
+ Array(vector).map { |component| float(finite(component)) }.join(",")
74
+ end
75
+
76
+ # A US dollar amount in the fixed point form the server's money parser
77
+ # takes: digits, an optional dot and at most 6 decimals (micro dollars),
78
+ # no sign, no exponent, never ".5" or "5.". Numbers are rounded to the
79
+ # nearest micro dollar; negative, NaN and infinite values raise, and so
80
+ # does a non zero amount too small to survive the rounding (it would
81
+ # otherwise silently become 0, which means "no limit" to LLMBUDGET.SET).
82
+ # Strings pass through untouched for the server to validate.
83
+ def usd(value)
84
+ return value if value.is_a?(String)
85
+ raise Error::ArgumentError, "expected a USD amount, got #{value.inspect}" unless value.is_a?(Numeric)
86
+
87
+ text = float(value)
88
+ raise Error::ArgumentError, "USD amount must be finite, got #{value}" if text.end_with?("inf")
89
+ raise Error::ArgumentError, "USD amount must not be negative, got #{value}" if text.start_with?("-") && text != "-0"
90
+
91
+ micros = (Rational(text) * 1_000_000).round
92
+ raise Error::ArgumentError, "USD amount #{text} is below one micro dollar" if micros.zero? && Rational(text) != 0
93
+
94
+ whole, frac = micros.divmod(1_000_000)
95
+ return whole.to_s if frac.zero?
96
+
97
+ "#{whole}.#{frac.to_s.rjust(6, '0').sub(/0+\z/, '')}"
98
+ end
99
+
100
+ def float_text(value)
101
+ raise Error::ArgumentError, "NaN is not a valid number" if value.nan?
102
+ return(value.positive? ? "inf" : "-inf") if value.infinite?
103
+
104
+ expand(value.to_s)
105
+ end
106
+ private_class_method :float_text
107
+
108
+ # BigDecimal goes through its own decimal string (BigDecimal#to_s("F"))
109
+ # so it is exact and needs no require of the bigdecimal library; any other
110
+ # Numeric goes through Float.
111
+ def decimal_text(value)
112
+ if value.class.name == "BigDecimal"
113
+ return float_text(value.to_f) if value.nan? || value.infinite?
114
+
115
+ return expand(value.to_s("F"))
116
+ end
117
+ float_text(Float(value))
118
+ end
119
+ private_class_method :decimal_text
120
+
121
+ # Rewrites "1.0e-05", "-2.5E+3", "0.1e-4", "123.456", "1e22" as plain
122
+ # decimal text with no exponent and no needless trailing zeros.
123
+ def expand(text)
124
+ m = FLOAT_PARTS.match(text) or raise Error::ArgumentError, "cannot format #{text.inspect} as a decimal"
125
+ sign, int, frac, exp = m.captures
126
+ digits = int + (frac || "")
127
+ point = int.length + exp.to_i # digits[0, point] is the integer part
128
+ if point <= 0
129
+ int_part = "0"
130
+ frac_part = ("0" * -point) + digits
131
+ elsif point >= digits.length
132
+ int_part = digits + ("0" * (point - digits.length))
133
+ frac_part = ""
134
+ else
135
+ int_part = digits[0, point]
136
+ frac_part = digits[point..]
137
+ end
138
+ int_part = int_part.sub(/\A0+(?=\d)/, "")
139
+ frac_part = frac_part.sub(/0+\z/, "")
140
+ out = frac_part.empty? ? int_part : "#{int_part}.#{frac_part}"
141
+ sign == "-" && out != "0" ? "-#{out}" : out
142
+ end
143
+ private_class_method :expand
144
+ end
145
+ end
data/lib/hotkv.rb ADDED
@@ -0,0 +1,28 @@
1
+ # Copyright (c) 2026 HotKV Ltd
2
+ # Licensed under the Apache License, Version 2.0. See LICENSE in the project root.
3
+
4
+ require_relative "hotkv/version"
5
+ require_relative "hotkv/errors"
6
+ require_relative "hotkv/url"
7
+ require_relative "hotkv/wire"
8
+ require_relative "hotkv/resp"
9
+ require_relative "hotkv/connection"
10
+ require_relative "hotkv/pool"
11
+ require_relative "hotkv/future"
12
+ require_relative "hotkv/usage"
13
+ require_relative "hotkv/llm_price"
14
+ require_relative "hotkv/commands/all"
15
+ require_relative "hotkv/pipeline"
16
+ require_relative "hotkv/transaction"
17
+ require_relative "hotkv/pubsub"
18
+ require_relative "hotkv/cluster/crc16"
19
+ require_relative "hotkv/cluster"
20
+ require_relative "hotkv/client"
21
+ require_relative "hotkv/ai/call_result"
22
+ require_relative "hotkv/ai/prompt_cache"
23
+ require_relative "hotkv/ai/semantic_cache"
24
+
25
+ # Official HotKV Ruby SDK. Start with HotKV::Client for a single server,
26
+ # or HotKV::Cluster for a HotKV Cluster deployment.
27
+ module HotKV
28
+ end