tollmeshcache 1.0.0 → 1.1.1

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -17
  3. data/lib/tollmeshcache.rb +408 -0
  4. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 949f75ceedd8c6f02fe2d4e67ba84179a453fbb4835937d79a47b6cf6ca816cf
4
- data.tar.gz: cbef69fcc4592c51e6a4aa77e46831a4984d8a9f24b921a2857f69f42431e7aa
3
+ metadata.gz: 672b49bc0e6e84e191e8eb5dcb063d77299fbcfd4e690b51d7516ca5e2e30010
4
+ data.tar.gz: eaaf7d0e919be5b229717a384b4c6c25b012d45a41e6a995992dd35fc075e976
5
5
  SHA512:
6
- metadata.gz: 954645035c80188ebd46400863da7c662c79294eca88957c75afcb7cca6c5cb94a7b5ecea33800a1b680186200dc41944ee44a1702e50cf8451e2f554e624883
7
- data.tar.gz: 899bb8e79b81a97f2f977cd35dbad72dfa12b0c6ccd88035d23c291d18200ae75c9a29845efb1f8cd7147da55d00f720ac45918e2c868a27348c17b85a3a4a63
6
+ metadata.gz: c07cf54e1265ca62f2dbd68ecb316e624bda434519391d0a114619b504a91059a4757dcc97d81e81f929fe2610b90ad6168dec77bb026c1888308eadc8da85aa
7
+ data.tar.gz: 3bdb306df5bf636a0a66913ab31531657e9f78b0bdfbda97c2d6cb42f3468a2bf55d62b9841ecce4a89410533cdd5a90cc26609e5d4ed939f50c3301add69817
data/README.md CHANGED
@@ -13,21 +13,25 @@ gem install tollmeshcache
13
13
  ```ruby
14
14
  require 'tollmeshcache'
15
15
 
16
- # Create a cache client
17
- cache = TollMeshCache::Client.new('localhost:8080')
18
-
19
- # Job Queues
20
- cache.enqueue('tasks', 'my-job', priority: 5)
21
- job = cache.claim('tasks')
22
- cache.complete('tasks', job.id)
23
-
24
- # Sorted Sets (Leaderboards)
25
- cache.zadd('scores', 100, 'player-1')
26
- cache.zrange('scores', 0, -1)
27
-
28
- # Streams (Event Logs)
29
- cache.xadd('events', { 'event' => 'login', 'user' => 'alice' })
30
- cache.xrange('events', '-', '+')
16
+ config = TollMeshCache::ClientConfig.new(host: 'localhost', port: 8080)
17
+ client = TollMeshCache::Client.new(config)
18
+
19
+ # Job Queues - distributed task processing
20
+ job = client.enqueue('tasks', 'my-job', priority: 5)
21
+ claimed = client.claim('tasks', 'worker-1')
22
+ client.complete('tasks', claimed['id'])
23
+
24
+ # Sorted Sets - O(log n) leaderboards
25
+ client.zadd('scores', 100, 'player-1')
26
+ client.zadd('scores', 150, 'player-2')
27
+ top_scores = client.zrevrange('scores', limit: 10) # highest first
28
+
29
+ # Streams - append-only event logs
30
+ entry = client.xadd('events', { 'event' => 'login', 'user' => 'alice' })
31
+ client.xgroup_create('events', 'analytics')
32
+ client.xreadgroup('analytics', 'worker-1', 'events').each do |e|
33
+ client.xack('events', 'analytics', 'worker-1', e['id'])
34
+ end
31
35
  ```
32
36
 
33
37
  ## Features
@@ -36,11 +40,10 @@ cache.xrange('events', '-', '+')
36
40
  - **Sorted Sets**: O(log n) leaderboards and rankings
37
41
  - **Streams**: Append-only event logs with consumer groups
38
42
  - **CRDT-based**: Eventual consistency without central coordinator
39
- - **Async/Await**: Full async support with Fiber
40
43
 
41
44
  ## Documentation
42
45
 
43
- See https://github.com/toll-mesh/store for complete documentation.
46
+ See https://github.com/TollMesh/toll-mesh-store for complete documentation.
44
47
 
45
48
  ## License
46
49
 
@@ -0,0 +1,408 @@
1
+ require 'httpclient'
2
+ require 'json'
3
+
4
+ module TollMeshCache
5
+ class Error < StandardError; end
6
+ class RateLimitError < Error; end
7
+ class ReplayError < Error; end
8
+ class CacheMissError < Error; end
9
+
10
+ class ClientConfig
11
+ attr_accessor :host, :port, :timeout, :verify_ssl, :api_key, :scheme
12
+
13
+ def initialize(host: 'localhost', port: 8080, timeout: 5, verify_ssl: true, scheme: 'http')
14
+ @host = host
15
+ @port = port
16
+ @timeout = timeout
17
+ @verify_ssl = verify_ssl
18
+ @scheme = scheme
19
+ @api_key = nil
20
+ end
21
+
22
+ def base_url
23
+ "#{@scheme}://#{@host}:#{@port}"
24
+ end
25
+ end
26
+
27
+ class Client
28
+ def initialize(config = nil)
29
+ @config = config || ClientConfig.new
30
+ @http = HTTPClient.new
31
+ @http.receive_timeout = @config.timeout
32
+ end
33
+
34
+ def consume(key, limit, window_ms)
35
+ body = {
36
+ key: key,
37
+ limit: limit,
38
+ window: window_ms
39
+ }
40
+ post('/consume', body)
41
+ end
42
+
43
+ def seen(key, ttl_ms)
44
+ body = { key: key, ttl: ttl_ms }
45
+ post('/seen', body)
46
+ end
47
+
48
+ def cache_get(namespace, key)
49
+ # /cache/get is a GET endpoint taking query params (see
50
+ # api/http.go handleCacheGet), not a POST with a JSON body.
51
+ get('/cache/get', query: { namespace: namespace, key: key })
52
+ end
53
+
54
+ def cache_set(namespace, key, value, ttl_ms = nil)
55
+ body = {
56
+ namespace: namespace,
57
+ key: key,
58
+ value: value
59
+ }
60
+ body[:ttl] = ttl_ms if ttl_ms
61
+ post('/cache/set', body)
62
+ end
63
+
64
+ def health
65
+ get('/health')
66
+ end
67
+
68
+ def get_peers
69
+ response = get('/peers')
70
+ response['peers'] || []
71
+ end
72
+
73
+ # ===== Job Queues =====
74
+
75
+ def enqueue(queue, payload, priority: 5, max_retries: 3, deadline_ms: nil)
76
+ body = { queue: queue, payload: payload, priority: priority, max_retries: max_retries }
77
+ body[:deadline] = deadline_ms if deadline_ms
78
+ post('/queue/enqueue', body)
79
+ end
80
+
81
+ def claim(queue, worker_id)
82
+ post('/queue/claim', { queue: queue, worker_id: worker_id })
83
+ end
84
+
85
+ def complete(queue, job_id, result = '')
86
+ post('/queue/complete', { queue: queue, job_id: job_id, result: result })
87
+ end
88
+
89
+ def fail_job(queue, job_id, error)
90
+ post('/queue/fail', { queue: queue, job_id: job_id, error: error })
91
+ end
92
+
93
+ def job_status(queue, job_id)
94
+ get('/queue/status', query: { queue: queue, job_id: job_id })
95
+ end
96
+
97
+ def queue_stats(queue)
98
+ get('/queue/stats', query: { queue: queue })
99
+ end
100
+
101
+ # ===== Sorted Sets =====
102
+
103
+ def zadd(key, score, member)
104
+ post('/zset/add', { key: key, member: member, score: score })
105
+ end
106
+
107
+ def zrem(key, member)
108
+ post('/zset/remove', { key: key, member: member })
109
+ end
110
+
111
+ def zscore(key, member)
112
+ response = get('/zset/score', query: { key: key, member: member })
113
+ [response['score'], response['exists']]
114
+ end
115
+
116
+ def zrank(key, member)
117
+ response = get('/zset/rank', query: { key: key, member: member })
118
+ [response['rank'], response['exists']]
119
+ end
120
+
121
+ def zrevrank(key, member)
122
+ response = get('/zset/revrank', query: { key: key, member: member })
123
+ [response['rank'], response['exists']]
124
+ end
125
+
126
+ def zrange(key, min: -Float::INFINITY, max: Float::INFINITY, limit: 100)
127
+ response = get('/zset/range', query: { key: key, min: min, max: max, limit: limit })
128
+ response['members'] || []
129
+ end
130
+
131
+ def zrevrange(key, max: Float::INFINITY, min: -Float::INFINITY, limit: 100)
132
+ response = get('/zset/revrange', query: { key: key, max: max, min: min, limit: limit })
133
+ response['members'] || []
134
+ end
135
+
136
+ def zcard(key)
137
+ response = get('/zset/card', query: { key: key })
138
+ response['card'] || 0
139
+ end
140
+
141
+ # ===== Streams =====
142
+
143
+ def xadd(stream, fields)
144
+ post('/stream/add', { stream: stream, fields: fields })
145
+ end
146
+
147
+ def xrange(stream, start = '0', end_id = '-', limit: 100)
148
+ response = get('/stream/range', query: { stream: stream, start: start, end: end_id, limit: limit })
149
+ response['entries'] || []
150
+ end
151
+
152
+ def xlen(stream)
153
+ response = get('/stream/len', query: { stream: stream })
154
+ response['length'] || 0
155
+ end
156
+
157
+ def xgroup_create(stream, group)
158
+ post('/stream/group/create', { stream: stream, group: group })
159
+ end
160
+
161
+ def xreadgroup(group, consumer, stream, limit: 100)
162
+ response = post('/stream/group/read', { stream: stream, group: group, consumer: consumer, limit: limit })
163
+ response['entries'] || []
164
+ end
165
+
166
+ def xack(stream, group, consumer, entry_id)
167
+ post('/stream/group/ack', { stream: stream, group: group, consumer: consumer, id: entry_id })
168
+ end
169
+
170
+ # ===== Pub/Sub =====
171
+
172
+ def subscribe(subscriber_id, topic, pattern: '')
173
+ post('/pubsub/subscribe', { subscriber_id: subscriber_id, topic: topic, pattern: pattern })
174
+ end
175
+
176
+ def unsubscribe(subscriber_id, topic)
177
+ post('/pubsub/unsubscribe', { subscriber_id: subscriber_id, topic: topic })
178
+ end
179
+
180
+ def publish(topic, publisher, payload)
181
+ response = post('/pubsub/publish', { topic: topic, publisher: publisher, payload: payload })
182
+ response['delivered_count'] || 0
183
+ end
184
+
185
+ def poll(subscriber_id, limit: 10, timeout_ms: 5000)
186
+ response = post('/pubsub/poll', { subscriber_id: subscriber_id, limit: limit, timeout_ms: timeout_ms })
187
+ response['messages'] || []
188
+ end
189
+
190
+ def get_topics
191
+ response = get('/pubsub/topics')
192
+ response['topics'] || []
193
+ end
194
+
195
+ def get_topic_subscribers(topic)
196
+ response = get('/pubsub/subscribers', query: { topic: topic })
197
+ response['subscribers'] || []
198
+ end
199
+
200
+ def pubsub_stats
201
+ get('/pubsub/stats')
202
+ end
203
+
204
+ # ===== Transactions =====
205
+
206
+ def begin_transaction(txn_id)
207
+ post('/txn/begin', { txn_id: txn_id })
208
+ end
209
+
210
+ def add_transaction_operation(txn_id, type, namespace, key, value = '')
211
+ post('/txn/operation', { txn_id: txn_id, type: type, namespace: namespace, key: key, value: value })
212
+ end
213
+
214
+ def commit_transaction(txn_id)
215
+ post('/txn/commit', { txn_id: txn_id })
216
+ end
217
+
218
+ def rollback_transaction(txn_id)
219
+ post('/txn/rollback', { txn_id: txn_id })
220
+ end
221
+
222
+ def transaction_status(txn_id)
223
+ response = get('/txn/status', query: { txn_id: txn_id })
224
+ response['status']
225
+ end
226
+
227
+ # ===== Persistence =====
228
+
229
+ def create_snapshot
230
+ post('/persistence/snapshot', {})
231
+ end
232
+
233
+ def get_latest_snapshot
234
+ get('/persistence/snapshot/latest')
235
+ rescue Error
236
+ nil
237
+ end
238
+
239
+ def restore_from_latest_snapshot
240
+ post('/persistence/restore', {})
241
+ end
242
+
243
+ def persistence_stats
244
+ get('/persistence/stats')
245
+ end
246
+
247
+ # ===== Scripting: Pipelines (safe operation composition) =====
248
+
249
+ def register_pipeline(name, steps)
250
+ post('/pipeline/register', { name: name, steps: steps })
251
+ end
252
+
253
+ def execute_pipeline(name)
254
+ post('/pipeline/execute', { name: name })
255
+ end
256
+
257
+ def execute_inline_pipeline(steps)
258
+ post('/pipeline/execute-inline', { steps: steps })
259
+ end
260
+
261
+ def get_pipeline(name)
262
+ get('/pipeline/get', query: { name: name })
263
+ end
264
+
265
+ def list_pipelines
266
+ response = get('/pipeline/list')
267
+ response['pipelines'] || []
268
+ end
269
+
270
+ def delete_pipeline(name)
271
+ post('/pipeline/delete', { name: name })
272
+ end
273
+
274
+ # ===== Scripting: WASM (real arbitrary Go code execution) =====
275
+
276
+ def compile_script(name, source)
277
+ post('/script/compile', { name: name, source: source })
278
+ end
279
+
280
+ def execute_script(name, input = '')
281
+ response = post('/script/execute', { name: name, input: input })
282
+ response['output'] || ''
283
+ end
284
+
285
+ def execute_inline_script(source, input = '')
286
+ response = post('/script/execute-inline', { source: source, input: input })
287
+ response['output'] || ''
288
+ end
289
+
290
+ def get_script(name)
291
+ get('/script/get', query: { name: name })
292
+ end
293
+
294
+ def list_scripts
295
+ response = get('/script/list')
296
+ response['scripts'] || []
297
+ end
298
+
299
+ def delete_script(name)
300
+ post('/script/delete', { name: name })
301
+ end
302
+
303
+ # ===== Search =====
304
+
305
+ def index_document(id, content, metadata: nil, vector: nil)
306
+ body = { id: id, content: content }
307
+ body[:metadata] = metadata if metadata
308
+ body[:vector] = vector if vector
309
+ post('/search/index', body)
310
+ end
311
+
312
+ def search_bm25(query, top_k: 10)
313
+ response = get('/search/bm25', query: { query: query, topk: top_k })
314
+ response['results'] || []
315
+ end
316
+
317
+ def search_vector(vector, top_k: 10)
318
+ response = post('/search/vector', { vector: vector, topk: top_k })
319
+ response['results'] || []
320
+ end
321
+
322
+ def search_hybrid(query, vector, top_k: 10)
323
+ response = post('/search/hybrid', { query: query, vector: vector, topk: top_k })
324
+ response['results'] || []
325
+ end
326
+
327
+ def delete_search_document(id)
328
+ post('/search/delete', { id: id })
329
+ end
330
+
331
+ # ===== Ranking =====
332
+
333
+ def rank(items, strategy: 'bm25', boosts: nil)
334
+ body = { items: items, strategy: strategy }
335
+ body[:boosts] = boosts if boosts
336
+ response = post('/rank', body)
337
+ response['items'] || []
338
+ end
339
+
340
+ # ===== Metrics =====
341
+
342
+ def get_metrics
343
+ get('/metrics')
344
+ end
345
+
346
+ def get_prometheus_metrics
347
+ url = @config.base_url + '/metrics/prometheus'
348
+ response = @http.get(url, header: { 'User-Agent' => 'tollmeshcache-ruby/1.1.1' })
349
+ response.body
350
+ end
351
+
352
+ def close
353
+ # HTTPClient (the httpclient gem) has no #close method -- calling it
354
+ # raised NoMethodError on every single client.close call, which is
355
+ # documented as the normal cleanup pattern (see README/examples).
356
+ # #reset_all is the gem's actual method for tearing down its
357
+ # keep-alive sessions.
358
+ @http.reset_all if @http
359
+ end
360
+
361
+ private
362
+
363
+ def post(endpoint, body)
364
+ url = @config.base_url + endpoint
365
+ headers = {
366
+ 'Content-Type' => 'application/json',
367
+ 'User-Agent' => 'tollmeshcache-ruby/1.1.1'
368
+ }
369
+ headers['X-API-Key'] = @config.api_key if @config.api_key
370
+
371
+ response = @http.post(url, JSON.generate(body), headers)
372
+ handle_response(response)
373
+ end
374
+
375
+ def get(endpoint, query: nil)
376
+ url = @config.base_url + endpoint
377
+ headers = { 'User-Agent' => 'tollmeshcache-ruby/1.1.1' }
378
+ headers['X-API-Key'] = @config.api_key if @config.api_key
379
+
380
+ response = @http.get(url, query: query, header: headers)
381
+ handle_response(response)
382
+ end
383
+
384
+ def handle_response(response)
385
+ if response.status >= 400
386
+ data = JSON.parse(response.body) rescue { 'code' => response.status }
387
+ code = data['code'] || response.status
388
+ # /consume, /seen, /cache/* use "message"; the job queue, sorted
389
+ # set, and stream endpoints use ErrorResponse{"error": ...} from
390
+ # api/http.go.
391
+ message = data['message'] || data['error'] || "HTTP #{response.status}"
392
+
393
+ case code
394
+ when 429
395
+ raise RateLimitError, message
396
+ when 1001
397
+ raise ReplayError, message
398
+ when 1002
399
+ raise CacheMissError, message
400
+ else
401
+ raise Error, message
402
+ end
403
+ end
404
+
405
+ JSON.parse(response.body)
406
+ end
407
+ end
408
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tollmeshcache
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - TollMesh Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: httpclient
@@ -131,6 +131,7 @@ extra_rdoc_files: []
131
131
  files:
132
132
  - LICENSE
133
133
  - README.md
134
+ - lib/tollmeshcache.rb
134
135
  homepage: https://github.com/toll-mesh/store
135
136
  licenses:
136
137
  - Apache-2.0