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
@@ -0,0 +1,261 @@
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
+ module Namespaces
6
+ # ACL: users and their permissions (core, not licensed).
7
+ class ACL
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ # Creates or modifies a user. +rules+ are the ACL rule strings
13
+ # ("on", ">password", "~pattern", "+get", ...); every rule applies or
14
+ # none does.
15
+ def setuser(username, *rules)
16
+ @client.invoke("ACL", "SETUSER", username, *rules.flatten) { |r| r == "OK" }
17
+ end
18
+
19
+ # Hash of the user's flags, passwords (SHA-256 hex), commands, keys,
20
+ # channels, databases and selectors; nil when the user does not exist.
21
+ def getuser(username)
22
+ @client.invoke("ACL", "GETUSER", username) { |r| r.nil? ? nil : hash_of(r) }
23
+ end
24
+
25
+ # Deletes users and disconnects their clients. Returns how many existed.
26
+ def deluser(*usernames)
27
+ @client.invoke("ACL", "DELUSER", *usernames.flatten)
28
+ end
29
+
30
+ # Every user as an ACL file line.
31
+ def list
32
+ @client.invoke("ACL", "LIST")
33
+ end
34
+
35
+ def users
36
+ @client.invoke("ACL", "USERS")
37
+ end
38
+
39
+ # The user of the connection that runs it.
40
+ def whoami
41
+ @client.invoke("ACL", "WHOAMI")
42
+ end
43
+
44
+ # The categories, or the commands in +category+.
45
+ def cat(category = nil)
46
+ category ? @client.invoke("ACL", "CAT", category) : @client.invoke("ACL", "CAT")
47
+ end
48
+
49
+ # A random password of +bits+ bits (default 256) as hex.
50
+ def genpass(bits = nil)
51
+ bits ? @client.invoke("ACL", "GENPASS", bits) : @client.invoke("ACL", "GENPASS")
52
+ end
53
+
54
+ # Denied commands and failed logins (the newest +count+ entries).
55
+ def log(count = nil)
56
+ count ? @client.invoke("ACL", "LOG", count) : @client.invoke("ACL", "LOG")
57
+ end
58
+
59
+ def log_reset
60
+ @client.invoke("ACL", "LOG", "RESET") { |r| r == "OK" }
61
+ end
62
+
63
+ # "OK" when +username+ may run the command, else the reason.
64
+ def dryrun(username, *command)
65
+ @client.invoke("ACL", "DRYRUN", username, *command.flatten)
66
+ end
67
+
68
+ private
69
+
70
+ def hash_of(reply)
71
+ reply.is_a?(Hash) ? reply : reply.each_slice(2).to_h
72
+ end
73
+ end
74
+
75
+ # Multi-tenancy (TENANT.*, licensed).
76
+ class Tenant
77
+ def initialize(client)
78
+ @client = client
79
+ end
80
+
81
+ # Creates tenant +id+ with display +name+ and optional limits.
82
+ def create(id, name, max_memory: nil, max_keys: nil, max_connections: nil, max_commands_sec: nil,
83
+ max_databases: nil, max_egress: nil)
84
+ args = ["TENANT.CREATE", id, name]
85
+ args.push("MAX_MEMORY", max_memory) if max_memory
86
+ args.push("MAX_KEYS", max_keys) if max_keys
87
+ args.push("MAX_CONNECTIONS", max_connections) if max_connections
88
+ args.push("MAX_COMMANDS_SEC", max_commands_sec) if max_commands_sec
89
+ args.push("MAX_DATABASES", max_databases) if max_databases
90
+ args.push("MAX_EGRESS", max_egress) if max_egress
91
+ @client.invoke(*args) { |r| r == "OK" }
92
+ end
93
+
94
+ def drop(id)
95
+ @client.invoke("TENANT.DROP", id) { |r| r == 1 || r == "OK" }
96
+ end
97
+
98
+ # Tenant ids.
99
+ def list
100
+ @client.invoke("TENANT.LIST")
101
+ end
102
+
103
+ # Hash with :id, :name, :state and the usage counters.
104
+ def info(id)
105
+ @client.invoke("TENANT.INFO", id) { |r| Commands::Support.pairs_to_h(r) }
106
+ end
107
+
108
+ # Hash of usage counters and limits (:memory_bytes, :memory_limit, ...).
109
+ def stats(id)
110
+ @client.invoke("TENANT.STATS", id) { |r| Commands::Support.pairs_to_h(r) }
111
+ end
112
+
113
+ def suspend(id)
114
+ @client.invoke("TENANT.SUSPEND", id) { |r| r == "OK" }
115
+ end
116
+
117
+ def resume(id)
118
+ @client.invoke("TENANT.RESUME", id) { |r| r == "OK" }
119
+ end
120
+
121
+ # Binds ACL user +username+ to tenant +tenant_id+.
122
+ def bind(tenant_id, username)
123
+ @client.invoke("TENANT.BIND", tenant_id, username) { |r| r == "OK" }
124
+ end
125
+
126
+ def unbind(tenant_id, username)
127
+ @client.invoke("TENANT.UNBIND", tenant_id, username) { |r| r == 1 || r == "OK" }
128
+ end
129
+ end
130
+
131
+ # Enterprise IAM roles and sessions (IAM.*, licensed).
132
+ class IAM
133
+ def initialize(client)
134
+ @client = client
135
+ end
136
+
137
+ # +permissions+ is a comma separated String or an Array of command
138
+ # patterns; :key_prefixes restricts the role to keys with those prefixes.
139
+ def role_create(name, permissions, key_prefixes: nil, rate_limit: nil, memory_quota: nil)
140
+ args = ["IAM.ROLE.CREATE", name, Array(permissions).join(",")]
141
+ args.push("KEY_PREFIXES", Array(key_prefixes).join(",")) if key_prefixes
142
+ args.push("RATE_LIMIT", rate_limit) if rate_limit
143
+ args.push("MEMORY_QUOTA", memory_quota) if memory_quota
144
+ @client.invoke(*args) { |r| r == "OK" }
145
+ end
146
+
147
+ def role_delete(name)
148
+ @client.invoke("IAM.ROLE.DELETE", name) { |r| r == "OK" }
149
+ end
150
+
151
+ def role_list
152
+ @client.invoke("IAM.ROLE.LIST")
153
+ end
154
+
155
+ # Hash with :name, :permissions (comma joined String), :rate_limit and
156
+ # :memory_quota.
157
+ def role_info(name)
158
+ @client.invoke("IAM.ROLE.INFO", name) { |r| Commands::Support.pairs_to_h(r) }
159
+ end
160
+
161
+ def user_assign(username, rolename)
162
+ @client.invoke("IAM.USER.ASSIGN", username, rolename) { |r| r == "OK" }
163
+ end
164
+
165
+ # Active sessions as an Array of Hashes (:id, :username, :commands).
166
+ def session_list
167
+ @client.invoke("IAM.SESSION.LIST") { |r| r.map { |s| Commands::Support.pairs_to_h(s) } }
168
+ end
169
+
170
+ def session_kill(session_id)
171
+ @client.invoke("IAM.SESSION.KILL", session_id) { |r| r == "OK" }
172
+ end
173
+
174
+ # Authenticates +username+ against +source+ ("ldap", "oidc", "saml", or
175
+ # anything else for local auth). Returns the session id.
176
+ def auth(source, username, credential)
177
+ @client.invoke("IAM.AUTH", source, username, credential)
178
+ end
179
+ end
180
+
181
+ # Tamper evident audit log (AUDIT.*, licensed).
182
+ class Audit
183
+ def initialize(client)
184
+ @client = client
185
+ end
186
+
187
+ # Hash with :total_entries, :file_size_bytes, :chain_valid (Integer
188
+ # 0/1) and :last_sequence.
189
+ def info
190
+ @client.invoke("AUDIT.INFO") { |r| Commands::Support.pairs_to_h(r) }
191
+ end
192
+
193
+ # Verifies the whole hash chain. Returns the server's status line
194
+ # ("OK chain verified"); raises ServerError when tampering is detected.
195
+ def verify
196
+ @client.invoke("AUDIT.VERIFY")
197
+ end
198
+
199
+ # Writes the log to +file+ on the server (+format+ "jsonl", "csv" or "cef").
200
+ def export(file, format: nil)
201
+ args = ["AUDIT.EXPORT", file]
202
+ args.push("FORMAT", format) if format
203
+ @client.invoke(*args) { |r| r == "OK" }
204
+ end
205
+
206
+ # Renames the current log with a timestamp suffix and starts a new one.
207
+ def rotate
208
+ @client.invoke("AUDIT.ROTATE") { |r| r == "OK" }
209
+ end
210
+ end
211
+
212
+ # Encryption at rest (ENCRYPTION.*, licensed).
213
+ class Encryption
214
+ def initialize(client)
215
+ @client = client
216
+ end
217
+
218
+ # Hash with :enabled (Integer 0/1), :algorithm and :provider.
219
+ def info
220
+ @client.invoke("ENCRYPTION.INFO") { |r| Commands::Support.pairs_to_h(r) }
221
+ end
222
+
223
+ # Switches snapshots to the 32 byte key in +new_key_file+ (a path on
224
+ # the server).
225
+ def rotate(new_key_file)
226
+ @client.invoke("ENCRYPTION.ROTATE", new_key_file) { |r| r == "OK" }
227
+ end
228
+
229
+ # True when the encrypted file at +file+ (a server path) is intact.
230
+ def verify(file)
231
+ @client.invoke("ENCRYPTION.VERIFY", file) { |r| r == 1 }
232
+ end
233
+ end
234
+
235
+ # Replication control (REPLICATION.*, licensed).
236
+ class Replication
237
+ def initialize(client)
238
+ @client = client
239
+ end
240
+
241
+ # Hash with :role, :state, :repl_offset, :connected_replicas, ...
242
+ def info
243
+ @client.invoke("REPLICATION.INFO") { |r| Commands::Support.pairs_to_h(r) }
244
+ end
245
+
246
+ # Stops sending (primary) or applying (replica) writes.
247
+ def pause
248
+ @client.invoke("REPLICATION.PAUSE") { |r| r == "OK" }
249
+ end
250
+
251
+ def resume
252
+ @client.invoke("REPLICATION.RESUME") { |r| r == "OK" }
253
+ end
254
+
255
+ # Promotes this replica to primary (without cluster mode).
256
+ def failover
257
+ @client.invoke("REPLICATION.FAILOVER") { |r| r == "OK" }
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,172 @@
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
+ module Namespaces
6
+ # AMEM.* (AI agent memory; needs a license that includes the
7
+ # AgentMemory feature): conversation, semantic, working and episodic
8
+ # memory, plus cross-memory search. AMEM.SUMMARY.* (the AMemSummary
9
+ # feature) lives under the nested #summary accessor.
10
+ class AMem
11
+ attr_reader :summary
12
+
13
+ def initialize(client)
14
+ @client = client
15
+ @summary = Summary.new(client)
16
+ end
17
+
18
+ # -- conversation memory --
19
+
20
+ def conv_create(session_id, window: nil, ttl: nil)
21
+ args = ["AMEM.CONV.CREATE", session_id]
22
+ args.push("WINDOW", window) if window
23
+ args.push("TTL", ttl) if ttl
24
+ @client.invoke(*args) { |r| r == "OK" }
25
+ end
26
+
27
+ def conv_append(session_id, role, content)
28
+ @client.invoke("AMEM.CONV.APPEND", session_id, role, content) { |r| r == "OK" }
29
+ end
30
+
31
+ def conv_get(session_id)
32
+ @client.invoke("AMEM.CONV.GET", session_id) { |r| r.map { |row| labeled(row, %i[role content]) } }
33
+ end
34
+
35
+ def conv_window(session_id, n)
36
+ @client.invoke("AMEM.CONV.WINDOW", session_id, n) { |r| r.map { |row| labeled(row, %i[role content]) } }
37
+ end
38
+
39
+ def conv_clear(session_id)
40
+ @client.invoke("AMEM.CONV.CLEAR", session_id) { |r| r == 1 }
41
+ end
42
+
43
+ def conv_drop(session_id)
44
+ @client.invoke("AMEM.CONV.DROP", session_id) { |r| r == 1 }
45
+ end
46
+
47
+ # -- semantic memory (per-agent vector store) --
48
+
49
+ def sem_create(agent_id, dimensions)
50
+ @client.invoke("AMEM.SEM.CREATE", agent_id, dimensions) { |r| r == "OK" }
51
+ end
52
+
53
+ def sem_store(agent_id, key, content, vector, ttl: nil)
54
+ args = ["AMEM.SEM.STORE", agent_id, key, content, *Wire.finite_vector(vector)]
55
+ args.push("TTL", ttl) if ttl
56
+ @client.invoke(*args) { |r| r == "OK" }
57
+ end
58
+
59
+ def sem_recall(agent_id, k, vector)
60
+ @client.invoke("AMEM.SEM.RECALL", agent_id, k, *Wire.finite_vector(vector)) do |r|
61
+ r.map { |row| labeled(row, %i[key content similarity], float_fields: %i[similarity]) }
62
+ end
63
+ end
64
+
65
+ def sem_forget(agent_id, key)
66
+ @client.invoke("AMEM.SEM.FORGET", agent_id, key) { |r| r == 1 }
67
+ end
68
+
69
+ def sem_list(agent_id)
70
+ @client.invoke("AMEM.SEM.LIST", agent_id)
71
+ end
72
+
73
+ # -- working memory (per-session scratch key/value) --
74
+
75
+ def work_set(session_id, key, value, ttl: nil)
76
+ args = ["AMEM.WORK.SET", session_id, key, value]
77
+ args.push("TTL", ttl) if ttl
78
+ @client.invoke(*args) { |r| r == "OK" }
79
+ end
80
+
81
+ def work_get(session_id, key)
82
+ @client.invoke("AMEM.WORK.GET", session_id, key)
83
+ end
84
+
85
+ def work_del(session_id, key)
86
+ @client.invoke("AMEM.WORK.DEL", session_id, key) { |r| r == 1 }
87
+ end
88
+
89
+ def work_clear(session_id)
90
+ @client.invoke("AMEM.WORK.CLEAR", session_id) { |r| r == 1 }
91
+ end
92
+
93
+ def work_list(session_id)
94
+ @client.invoke("AMEM.WORK.LIST", session_id)
95
+ end
96
+
97
+ # -- episodic memory (append only event log) --
98
+
99
+ def epis_log(agent_id, event_type, content, metadata: nil)
100
+ args = ["AMEM.EPIS.LOG", agent_id, event_type, content]
101
+ args.push("METADATA", metadata) if metadata
102
+ @client.invoke(*args) { |r| r == "OK" }
103
+ end
104
+
105
+ def epis_query(agent_id, type: nil, from: nil, to: nil, limit: nil)
106
+ args = ["AMEM.EPIS.QUERY", agent_id]
107
+ args.push("TYPE", type) if type
108
+ args.push("FROM", from) if from
109
+ args.push("TO", to) if to
110
+ args.push("LIMIT", limit) if limit
111
+ @client.invoke(*args) { |r| r.map { |row| labeled(row, %i[type content metadata timestamp]) } }
112
+ end
113
+
114
+ def epis_clear(agent_id)
115
+ @client.invoke("AMEM.EPIS.CLEAR", agent_id) { |r| r == 1 }
116
+ end
117
+
118
+ def epis_stats(agent_id)
119
+ @client.invoke("AMEM.EPIS.STATS", agent_id) { |r| Commands::Support.pairs_to_h(r) }
120
+ end
121
+
122
+ # -- cross-memory search (semantic + episodic + working together) --
123
+
124
+ def search(agent_id, k, vector)
125
+ @client.invoke("AMEM.SEARCH", agent_id, k, *Wire.finite_vector(vector)) do |r|
126
+ r.map { |row| labeled(row, %i[source key content similarity], float_fields: %i[similarity]) }
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ # A "labeled flat array" reply looks like ["role", <role>, "content",
133
+ # <content>]: fixed label tokens paired with their values. Unwraps
134
+ # it into a Hash keyed by +fields+, in the order the server documents.
135
+ def labeled(row, fields, float_fields: [])
136
+ h = {}
137
+ fields.each_with_index { |f, i| h[f] = row[(i * 2) + 1] }
138
+ float_fields.each { |f| h[f] = Wire.parse_float(h[f]) }
139
+ h
140
+ end
141
+
142
+ # AMEM.SUMMARY.* (needs the AMemSummary license feature).
143
+ class Summary
144
+ def initialize(client)
145
+ @client = client
146
+ end
147
+
148
+ def set(agent_id, summary_text)
149
+ @client.invoke("AMEM.SUMMARY.SET", agent_id, summary_text)
150
+ end
151
+
152
+ def append(agent_id, extra)
153
+ @client.invoke("AMEM.SUMMARY.APPEND", agent_id, extra)
154
+ end
155
+
156
+ def get(agent_id)
157
+ @client.invoke("AMEM.SUMMARY.GET", agent_id) do |r|
158
+ r.nil? ? nil : { summary: r[0], version: r[1], updated_unix: r[2] }
159
+ end
160
+ end
161
+
162
+ def drop(agent_id)
163
+ @client.invoke("AMEM.SUMMARY.DROP", agent_id) { |r| r == 1 }
164
+ end
165
+
166
+ def list
167
+ @client.invoke("AMEM.SUMMARY.LIST")
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end