prosody 0.3.0 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +3 -0
  3. data/.release-please-manifest.json +1 -1
  4. data/AGENTS.md +395 -0
  5. data/ARCHITECTURE.md +14 -4
  6. data/CHANGELOG.md +28 -0
  7. data/CLAUDE.md +1 -0
  8. data/CONFIGURATION.md +167 -0
  9. data/Cargo.lock +1115 -645
  10. data/Cargo.toml +7 -6
  11. data/README.md +436 -146
  12. data/Rakefile +11 -1
  13. data/examples/keyed_state.rb +70 -0
  14. data/examples/keyed_state.rbs +18 -0
  15. data/examples/keyed_state_windowing.rb +55 -0
  16. data/examples/keyed_state_windowing.rbs +16 -0
  17. data/ext/prosody/Cargo.toml +1 -0
  18. data/ext/prosody/src/admin.rs +1 -5
  19. data/ext/prosody/src/bridge/mod.rs +17 -32
  20. data/ext/prosody/src/client/config.rs +501 -28
  21. data/ext/prosody/src/client/mod.rs +167 -74
  22. data/ext/prosody/src/client/request.rs +132 -0
  23. data/ext/prosody/src/client/support.rs +122 -0
  24. data/ext/prosody/src/handler/context.rs +150 -5
  25. data/ext/prosody/src/handler/message.rs +67 -0
  26. data/ext/prosody/src/handler/mod.rs +115 -85
  27. data/ext/prosody/src/handler/state/mod.rs +488 -0
  28. data/ext/prosody/src/handler/state/registration.rs +104 -0
  29. data/ext/prosody/src/handler/state/scan.rs +218 -0
  30. data/ext/prosody/src/lib.rs +15 -3
  31. data/ext/prosody/src/published.rs +273 -0
  32. data/ext/prosody/src/scheduler/mod.rs +2 -2
  33. data/ext/prosody/src/scheduler/processor.rs +2 -2
  34. data/ext/prosody/src/scheduler/result.rs +7 -4
  35. data/ext/prosody/src/util.rs +86 -5
  36. data/lib/prosody/configuration.rb +71 -11
  37. data/lib/prosody/handler.rb +65 -8
  38. data/lib/prosody/native_stubs.rb +550 -9
  39. data/lib/prosody/request.rb +45 -0
  40. data/lib/prosody/state.rb +816 -0
  41. data/lib/prosody/version.rb +1 -1
  42. data/lib/prosody.rb +6 -0
  43. data/release-please-config.json +4 -0
  44. data/sig/configuration.rbs +70 -11
  45. data/sig/handler.rbs +17 -5
  46. data/sig/processor.rbs +28 -12
  47. data/sig/prosody.rbs +53 -7
  48. data/sig/request.rbs +66 -0
  49. data/sig/sentry.rbs +6 -0
  50. data/sig/state.rbs +390 -0
  51. data/steep_expectations.yml +57 -0
  52. data/typecheck/payload_types.rb +54 -0
  53. data/typecheck/payload_types.rbs +22 -0
  54. data/typecheck_negative/payload_types.rb +20 -0
  55. data/typecheck_negative/payload_types.rbs +9 -0
  56. metadata +32 -9
@@ -0,0 +1,816 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prosody
4
+ # Base class for errors raised by keyed-state operations that will not
5
+ # succeed on retry (an unregistered collection name, an identity mismatch, a
6
+ # duplicate registration, an invalid TTL).
7
+ #
8
+ # It subclasses {PermanentError} so a rethrown state error is classified as
9
+ # permanent by the result bridge's `#permanent?` path with no bridge change.
10
+ #
11
+ # @see PermanentError
12
+ class PermanentStateError < PermanentError; end
13
+
14
+ # Base class for errors raised by keyed-state operations that may succeed on
15
+ # retry. Every caller/input mistake (a null write, a wrong item shape, an
16
+ # invalid index, an invalid direction token, an unrepresentable value) is
17
+ # transient so the message retries and stays visible rather than being
18
+ # discarded.
19
+ #
20
+ # It subclasses {TransientError} so a rethrown state error is classified as
21
+ # transient by the result bridge's `#permanent?` path with no bridge change.
22
+ #
23
+ # @see TransientError
24
+ class TransientStateError < TransientError; end
25
+
26
+ # Raised when a JSON `null` is written to a collection. `null` is not a
27
+ # storable value (it is indistinguishable from absence), so the write is
28
+ # rejected and the stored value is left untouched. Use `clear`/`delete` to
29
+ # express deletion instead.
30
+ #
31
+ # It is transient (a caller mistake), so it retries and stays visible.
32
+ #
33
+ # @see TransientStateError
34
+ class NullValueError < TransientStateError; end
35
+
36
+ # An immutable keyed-state collection definition.
37
+ #
38
+ # Definitions are frozen value objects produced by the {Prosody.value},
39
+ # {Prosody.map}, {Prosody.deque}, and their `message_*` siblings. A definition
40
+ # both serializes into `Configuration#state_collections` (via
41
+ # {#to_state_config}) so the collection is registered before subscribe, and
42
+ # drives {Prosody::Context#state} to vend the matching typed handle.
43
+ StateAccess = Data.define(:vend_method, :wrapper, :published_vend_method, :published_wrapper)
44
+ private_constant :StateAccess
45
+ VALUE_ACCESS = StateAccess.new(vend_method: :value_state, wrapper: :ValueState,
46
+ published_vend_method: :published_value, published_wrapper: :PublishedValue)
47
+ MAP_ACCESS = StateAccess.new(vend_method: :map_state, wrapper: :MapState,
48
+ published_vend_method: :published_map, published_wrapper: :PublishedMap)
49
+ DEQUE_ACCESS = StateAccess.new(vend_method: :deque_state, wrapper: :DequeState,
50
+ published_vend_method: :published_deque, published_wrapper: :PublishedDeque)
51
+ MESSAGE_VALUE_ACCESS = StateAccess.new(vend_method: :message_value_state, wrapper: :ValueState,
52
+ published_vend_method: nil, published_wrapper: nil)
53
+ MESSAGE_MAP_ACCESS = StateAccess.new(vend_method: :message_map_state, wrapper: :MapState,
54
+ published_vend_method: nil, published_wrapper: nil)
55
+ MESSAGE_DEQUE_ACCESS = StateAccess.new(vend_method: :message_deque_state, wrapper: :DequeState,
56
+ published_vend_method: nil, published_wrapper: nil)
57
+ private_constant :VALUE_ACCESS, :MAP_ACCESS, :DEQUE_ACCESS,
58
+ :MESSAGE_VALUE_ACCESS, :MESSAGE_MAP_ACCESS, :MESSAGE_DEQUE_ACCESS
59
+
60
+ StateDefinition = Data.define(:name, :kind, :payload, :ttl_seconds, :read_uncommitted,
61
+ :published, :read_cache, :keyset_limit, :capacity, :access) do
62
+ # Serializes this definition into the native-registration hash, omitting
63
+ # unset optionals so they fall back to the core defaults.
64
+ #
65
+ # @return [Hash] the registration hash for the native layer
66
+ def to_state_config
67
+ config = {name: name, kind: kind, payload: payload}
68
+ config[:ttl_seconds] = ttl_seconds unless ttl_seconds.nil?
69
+ config[:read_uncommitted] = read_uncommitted unless read_uncommitted.nil?
70
+ config[:published] = published unless published.nil?
71
+ config[:keyset_limit] = keyset_limit unless keyset_limit.nil?
72
+ config[:capacity] = capacity unless capacity.nil?
73
+ config
74
+ end
75
+ end
76
+
77
+ # Defines a single-value JSON collection.
78
+ #
79
+ # @param name [#to_s] the collection name (unique within the client)
80
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
81
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
82
+ # @return [StateDefinition] a frozen definition
83
+ def self.value(name, ttl: nil, read_uncommitted: nil, published: nil, read_cache: nil)
84
+ StateDefinition.new(name: name.to_s, kind: "value", payload: "json",
85
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: published,
86
+ read_cache: read_cache, keyset_limit: nil, capacity: nil,
87
+ access: VALUE_ACCESS)
88
+ end
89
+
90
+ # Defines a `String`-keyed ordered map JSON collection.
91
+ #
92
+ # @param name [#to_s] the collection name (unique within the client)
93
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
94
+ # @param keyset_limit [Integer, nil] optional map-only keyset bound (`0..=4096`)
95
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
96
+ # @return [StateDefinition] a frozen definition
97
+ def self.map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil, published: nil, read_cache: nil)
98
+ StateDefinition.new(name: name.to_s, kind: "map", payload: "json",
99
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: published,
100
+ read_cache: read_cache, keyset_limit: keyset_limit, capacity: nil,
101
+ access: MAP_ACCESS)
102
+ end
103
+
104
+ # Defines a deque JSON collection.
105
+ #
106
+ # @param name [#to_s] the collection name (unique within the client)
107
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
108
+ # @param capacity [Integer, nil] optional window bound (at least 1); the
109
+ # deque keeps at most this many slots, enforced lazily on push. Runtime-only
110
+ # and mutable across deploys, never persisted (see {DequeState#push}).
111
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
112
+ # @return [StateDefinition] a frozen definition
113
+ def self.deque(name, ttl: nil, capacity: nil, read_uncommitted: nil, published: nil, read_cache: nil)
114
+ StateDefinition.new(name: name.to_s, kind: "deque", payload: "json",
115
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: published,
116
+ read_cache: read_cache, keyset_limit: nil, capacity: capacity,
117
+ access: DEQUE_ACCESS)
118
+ end
119
+
120
+ # Defines a single-value Kafka-message collection (items are full messages).
121
+ #
122
+ # @param name [#to_s] the collection name (unique within the client)
123
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
124
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
125
+ # @return [StateDefinition] a frozen definition
126
+ def self.message_value(name, ttl: nil, read_uncommitted: nil)
127
+ StateDefinition.new(name: name.to_s, kind: "value", payload: "message",
128
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: nil,
129
+ read_cache: nil, keyset_limit: nil, capacity: nil,
130
+ access: MESSAGE_VALUE_ACCESS)
131
+ end
132
+
133
+ # Defines a `String`-keyed ordered map Kafka-message collection.
134
+ #
135
+ # @param name [#to_s] the collection name (unique within the client)
136
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
137
+ # @param keyset_limit [Integer, nil] optional map-only keyset bound (`0..=4096`)
138
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
139
+ # @return [StateDefinition] a frozen definition
140
+ def self.message_map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil)
141
+ StateDefinition.new(name: name.to_s, kind: "map", payload: "message",
142
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: nil,
143
+ read_cache: nil, keyset_limit: keyset_limit, capacity: nil,
144
+ access: MESSAGE_MAP_ACCESS)
145
+ end
146
+
147
+ # Defines a deque Kafka-message collection.
148
+ #
149
+ # @param name [#to_s] the collection name (unique within the client)
150
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
151
+ # @param capacity [Integer, nil] optional window bound (at least 1); the
152
+ # deque keeps at most this many slots, enforced lazily on push. Runtime-only
153
+ # and mutable across deploys, never persisted (see {DequeState#push}).
154
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
155
+ # @return [StateDefinition] a frozen definition
156
+ def self.message_deque(name, ttl: nil, capacity: nil, read_uncommitted: nil)
157
+ StateDefinition.new(name: name.to_s, kind: "deque", payload: "message",
158
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, published: nil,
159
+ read_cache: nil, keyset_limit: nil, capacity: capacity,
160
+ access: MESSAGE_DEQUE_ACCESS)
161
+ end
162
+
163
+ # Shared state wrapper behavior.
164
+ module State
165
+ module Reading
166
+ # Opens a read-only view of a published JSON collection.
167
+ def state(subsystem, definition)
168
+ access = definition.access
169
+ if access.published_vend_method.nil? || access.published_wrapper.nil?
170
+ raise ArgumentError, "published state readers support JSON collections only"
171
+ end
172
+ cache_seconds = definition.read_cache unless definition.read_cache == false
173
+ native = public_send(access.published_vend_method, subsystem.to_s, definition.name, cache_seconds,
174
+ definition.read_cache == false)
175
+ Prosody.const_get(access.published_wrapper).new(native)
176
+ end
177
+ end
178
+
179
+ # Adds keyed-state vending to the native context. Included into
180
+ # {Prosody::Context}; kept as a module so the routing can be exercised
181
+ # against a stand-in receiver.
182
+ module Vending
183
+ # Vends the typed keyed-state handle for `definition`.
184
+ #
185
+ # Handles are cached per context by kind, payload, and name, so repeated
186
+ # vends within one handler invocation return the same wrapper.
187
+ #
188
+ # @param definition [StateDefinition] a frozen collection definition
189
+ # @return [ValueState, MapState, DequeState] the typed handle
190
+ # @raise [TransientStateError] if the definition's kind/payload is unknown
191
+ # @raise [PermanentStateError] if the collection name is unregistered or
192
+ # its registered identity mismatches
193
+ def state(definition)
194
+ cache = (@state_handles ||= {})
195
+ cache_key = "#{definition.kind}:#{definition.payload}:#{definition.name}"
196
+ return cache[cache_key] if cache.key?(cache_key)
197
+
198
+ native = public_send(definition.access.vend_method, definition.name)
199
+ cache[cache_key] = Prosody.const_get(definition.access.wrapper).new(native)
200
+ end
201
+ end
202
+
203
+ # Shared cursor-driving for the explicit-traversal handles. Folds the
204
+ # identical native-scan open/close/exhaustion loop; each handle supplies
205
+ # only the per-item yield shape through the block. Kept private (mixed into
206
+ # the handle classes) since it is not part of the public surface.
207
+ module Scanning
208
+ private
209
+
210
+ # Opens a native scan in `direction`, yields each item, and closes the
211
+ # scan via `ensure` on stop or exception. Direction validity is enforced
212
+ # by the native layer (an invalid token is rejected transient there); the
213
+ # public traversal methods only ever pass `:forward`/`:backward`. `opener`
214
+ # selects the native cursor seam — the default `:scan` yields values (or
215
+ # `[key, value]` pairs), `:keys` yields bare map keys.
216
+ def scan_each(direction, opener = :scan)
217
+ scan_items(@native.public_send(opener, direction)) { |item| yield item }
218
+ end
219
+
220
+ def scan_items(scan)
221
+ # `nil` is the exhaustion sentinel (unambiguous under the null ban);
222
+ # terminate on it explicitly rather than on falsiness, so a legal
223
+ # stored `false` (or a `[key, false]` pair, always a truthy Array)
224
+ # does not stop iteration and drop the tail after it.
225
+ until (item = scan.next).nil?
226
+ yield item
227
+ end
228
+ ensure
229
+ scan.close
230
+ end
231
+ end
232
+ end
233
+
234
+ class Client
235
+ include State::Reading
236
+ end
237
+
238
+ class PublishedValue
239
+ def initialize(native) = @native = native
240
+ def get(key) = @native.get(key.to_s)
241
+ end
242
+
243
+ class PublishedMap
244
+ include State::Scanning
245
+
246
+ def initialize(native) = @native = native
247
+ def get(key, map_key) = @native.get(key.to_s, map_key.to_s)
248
+ def get_many(key, map_keys) = @native.get_many(key.to_s, map_keys.map(&:to_s))
249
+ def key?(key, map_key) = @native.contains_key(key.to_s, map_key.to_s)
250
+ alias_method :has_key?, :key?
251
+ alias_method :include?, :key?
252
+ alias_method :member?, :key?
253
+
254
+ def each_pair(key, &block) = traverse(key, :forward, &block)
255
+ def reverse_each_pair(key, &block) = traverse(key, :backward, &block)
256
+ def each_key(key, &block) = traverse_keys(key, :forward, &block)
257
+ def reverse_each_key(key, &block) = traverse_keys(key, :backward, &block)
258
+ def each_value(key, &block) = traverse_values(key, :forward, &block)
259
+ def reverse_each_value(key, &block) = traverse_values(key, :backward, &block)
260
+ alias_method :each, :each_pair
261
+
262
+ private
263
+
264
+ def traverse(key, direction)
265
+ return enum_for(__method__, key, direction) unless block_given?
266
+
267
+ scan_items(@native.scan(key.to_s, direction)) { |entry| yield(*entry) }
268
+ end
269
+
270
+ def traverse_keys(key, direction)
271
+ return enum_for(__method__, key, direction) unless block_given?
272
+
273
+ scan_items(@native.keys(key.to_s, direction)) { |map_key| yield map_key }
274
+ end
275
+
276
+ def traverse_values(key, direction)
277
+ return enum_for(__method__, key, direction) unless block_given?
278
+
279
+ scan_items(@native.scan(key.to_s, direction)) { |entry| yield entry[1] }
280
+ end
281
+ end
282
+
283
+ class PublishedDeque
284
+ include State::Scanning
285
+
286
+ def initialize(native) = @native = native
287
+
288
+ def get(key, index)
289
+ unless index.is_a?(Integer)
290
+ raise TransientStateError, "get: index must be an Integer, got #{index.inspect}"
291
+ end
292
+
293
+ return @native.get(key.to_s, index) unless index.negative?
294
+ return last(key) if index == -1
295
+
296
+ resolved = length(key) + index
297
+ resolved.negative? ? nil : @native.get(key.to_s, resolved)
298
+ end
299
+
300
+ def length(key) = @native.length(key.to_s)
301
+ alias_method :size, :length
302
+ def empty?(key) = @native.is_empty(key.to_s)
303
+ def first(key) = @native.peek_front(key.to_s)
304
+ def last(key) = @native.peek_back(key.to_s)
305
+
306
+ def each(key, &block) = traverse(key, :forward, &block)
307
+ def reverse_each(key, &block) = traverse(key, :backward, &block)
308
+
309
+ private
310
+
311
+ def traverse(key, direction)
312
+ return enum_for(__method__, key, direction) unless block_given?
313
+
314
+ scan_items(@native.scan(key.to_s, direction)) { |item| yield item }
315
+ end
316
+ end
317
+
318
+ # A single-value keyed-state handle.
319
+ #
320
+ # Reads return the stored JSON value (or a {Prosody::Message} for message
321
+ # collections), or `nil` when the value is absent. Writes are buffered and
322
+ # made durable by {#commit}. All operations are fiber-yield async: they look
323
+ # blocking but never block the thread.
324
+ class ValueState
325
+ # @param native [Prosody::NativeJsonValueState, Prosody::NativeMessageValueState] the native handle
326
+ def initialize(native)
327
+ @native = native
328
+ end
329
+
330
+ # Reads the current value.
331
+ #
332
+ # @return [Object, nil] the stored value, or `nil` when absent
333
+ def get = @native.get
334
+
335
+ # Buffers a write of the value.
336
+ #
337
+ # @param value [Object] the value to store (JSON, or a message)
338
+ # @return [void]
339
+ # @raise [NullValueError] if `value` is `nil` (use {#clear} to delete)
340
+ def set(value) = @native.set(value)
341
+
342
+ # Buffers a clear of the value.
343
+ #
344
+ # @return [void]
345
+ def clear = @native.clear
346
+
347
+ # Durably commits the buffered operations mid-handler.
348
+ #
349
+ # @return [nil] the erased FFI seam drops the applied/no-op outcome
350
+ def commit = @native.commit
351
+
352
+ # Discards the buffered uncommitted operations.
353
+ #
354
+ # @return [nil]
355
+ def rollback = @native.rollback
356
+
357
+ # Reads the current value. Idiomatic alias of {#get}.
358
+ #
359
+ # @return [Object, nil]
360
+ alias_method :value, :get
361
+
362
+ # Buffers a write of the value. Idiomatic alias of {#set}. As with any Ruby
363
+ # writer, `state.value = x` evaluates to `x` regardless of the return.
364
+ #
365
+ # @param value [Object]
366
+ # @return [void]
367
+ alias_method :value=, :set
368
+ end
369
+
370
+ # A `String`-keyed ordered-map keyed-state handle.
371
+ #
372
+ # Traversal is explicit: {#each_pair}/{#reverse_each_pair} yield `key, value`
373
+ # pairs over a native scan, closing the scan via `ensure`. No aggregate-mixin
374
+ # methods are provided — they would silently materialize an unbounded remote
375
+ # collection.
376
+ class MapState
377
+ include State::Scanning
378
+
379
+ # @param native [Prosody::NativeJsonMapState, Prosody::NativeMessageMapState] the native handle
380
+ def initialize(native)
381
+ @native = native
382
+ end
383
+
384
+ # Reads the value for `key`.
385
+ #
386
+ # @param key [String] the map key
387
+ # @return [Object, nil] the value, or `nil` when the key is absent
388
+ def get(key) = @native.get(key)
389
+
390
+ # Reads several keys in a single isolated batch.
391
+ #
392
+ # @param keys [Array<String>] the keys to read, in order
393
+ # @return [Array<Object, nil>] one result per input key; `nil` for absent keys
394
+ def get_many(keys) = @native.get_many(keys)
395
+
396
+ # Inserts or overwrites `key`.
397
+ #
398
+ # @param key [String] the map key
399
+ # @param value [Object] the value to store (JSON, or a message)
400
+ # @return [void]
401
+ # @raise [NullValueError] if `value` is `nil` (use {#delete} to remove)
402
+ def set(key, value) = @native.set(key, value)
403
+
404
+ # Removes `key`.
405
+ #
406
+ # Documented divergence from `Hash#delete`: this returns `nil`, never the
407
+ # removed value (the erased FFI seam does not surface it).
408
+ #
409
+ # @param key [String] the map key
410
+ # @return [nil]
411
+ def delete(key)
412
+ @native.remove(key)
413
+ nil
414
+ end
415
+
416
+ # Removes every entry.
417
+ #
418
+ # @return [void]
419
+ def clear = @native.clear
420
+
421
+ # Durably commits the buffered operations mid-handler.
422
+ #
423
+ # @return [nil] the erased FFI seam drops the applied/no-op outcome
424
+ def commit = @native.commit
425
+
426
+ # Discards the buffered uncommitted operations.
427
+ #
428
+ # @return [nil]
429
+ def rollback = @native.rollback
430
+
431
+ # Traverses the live entries in key order, yielding `key, value`.
432
+ #
433
+ # Without a block, returns an {Enumerator} over the native scan. Each step
434
+ # fiber-yields; the scan is closed via `ensure` on stop or exception. The
435
+ # enumerator is valid only within the current handler invocation.
436
+ #
437
+ # @yieldparam key [String]
438
+ # @yieldparam value [Object]
439
+ # @return [Enumerator, void]
440
+ def each_pair(&block) = traverse(:forward, &block)
441
+
442
+ # Traverses the live entries in reverse key order, yielding `key, value`.
443
+ #
444
+ # @yieldparam key [String]
445
+ # @yieldparam value [Object]
446
+ # @return [Enumerator, void]
447
+ def reverse_each_pair(&block) = traverse(:backward, &block)
448
+
449
+ # Traverses the live keys in key order, yielding each key (mirrors
450
+ # +Hash#each_key+). The key scan skips value decode and the resolver — a
451
+ # message-backed map yields keys with zero Kafka fetches, though not
452
+ # zero-I/O. Without a block, returns a demand-driven {Enumerator}; there is
453
+ # deliberately no eager +keys+ array (it would materialize the whole remote
454
+ # keyset). Mirrors {#each_pair}'s block-form return (+nil+), not stdlib's
455
+ # +self+, for in-repo sibling consistency.
456
+ #
457
+ # @yieldparam key [String]
458
+ # @return [Enumerator, void]
459
+ def each_key(&block) = traverse_keys(:forward, &block)
460
+
461
+ # Traverses the live keys in reverse key order, yielding each key.
462
+ #
463
+ # @yieldparam key [String]
464
+ # @return [Enumerator, void]
465
+ def reverse_each_key(&block) = traverse_keys(:backward, &block)
466
+ def each_value(&block) = traverse_values(:forward, &block)
467
+ def reverse_each_value(&block) = traverse_values(:backward, &block)
468
+
469
+ # --- idiomatic Hash-style aliases and conveniences ------------------
470
+ # Each is composed from the canonical ops above and adds no capability
471
+ # the naming matrix lacks. Bounded reads only: there is deliberately no
472
+ # +keys+/+values+/+to_h+/+count+ or +Enumerable+, which would materialize
473
+ # the whole (potentially unbounded) remote collection.
474
+
475
+ # Reads +key+. Idiomatic alias of {#get} (mirrors +Hash#[]+).
476
+ alias_method :[], :get
477
+
478
+ # Writes +key+. Idiomatic alias of {#set} (mirrors +Hash#[]=+). As with any
479
+ # Ruby +[]=+, `map[key] = value` evaluates to +value+ regardless of return.
480
+ alias_method :[]=, :set
481
+
482
+ # Writes +key+, returning the stored +value+ (mirrors +Hash#store+). A
483
+ # wrapper, not an alias: unlike +[]=+, +store+ is called normally, so its
484
+ # return is observed — and the native write returns +nil+.
485
+ #
486
+ # @param key [String]
487
+ # @param value [Object]
488
+ # @return [Object] the stored +value+
489
+ def store(key, value)
490
+ set(key, value)
491
+ value
492
+ end
493
+
494
+ # Traverses live entries in key order. Idiomatic alias of {#each_pair}
495
+ # (mirrors +Hash#each+).
496
+ alias_method :each, :each_pair
497
+
498
+ # Reads several keys positionally (mirrors +Hash#values_at+).
499
+ #
500
+ # @param keys [Array<String>] the keys to read
501
+ # @return [Array<Object, nil>] one result per key; +nil+ for absent keys
502
+ def values_at(*keys) = get_many(keys)
503
+
504
+ # Reads +key+, raising or defaulting when absent (mirrors +Hash#fetch+).
505
+ # Performs a single read; a +nil+ result is unambiguously "absent" under
506
+ # the null ban.
507
+ #
508
+ # @param key [String]
509
+ # @param default [Object] returned when +key+ is absent
510
+ # @yieldparam key [String] called (instead of +default+) when +key+ is absent
511
+ # @return [Object]
512
+ # @raise [KeyError] when +key+ is absent and no default or block is given
513
+ def fetch(key, *default, &block)
514
+ if default.length > 1
515
+ raise ArgumentError, "wrong number of arguments (given #{default.length + 1}, expected 1..2)"
516
+ end
517
+ warn "warning: block supersedes default value argument" if block && !default.empty?
518
+
519
+ value = @native.get(key)
520
+ return value unless value.nil?
521
+ return block.call(key) if block
522
+ return default.first unless default.empty?
523
+
524
+ raise KeyError.new("key not found: #{key.inspect}", key: key, receiver: self)
525
+ end
526
+
527
+ # Whether +key+ has a live value (mirrors +Hash#key?+). A presence check:
528
+ # no value decode and no resolver run (not no-I/O). A message-backed map
529
+ # answers presence with zero Kafka fetches — +true+ even for a
530
+ # present-but-unfetchable cell — though a cache miss may still touch the
531
+ # store.
532
+ #
533
+ # @param key [String]
534
+ # @return [Boolean]
535
+ def key?(key) = @native.contains_key(key)
536
+ alias_method :has_key?, :key?
537
+ alias_method :include?, :key?
538
+ alias_method :member?, :key?
539
+
540
+ # Reads +key+ and digs into the nested value (mirrors +Hash#dig+). A single
541
+ # bounded read; digging continues in the returned local value.
542
+ #
543
+ # @param key [String]
544
+ # @return [Object, nil]
545
+ # @raise [TypeError] if a nested value does not respond to +dig+
546
+ def dig(key, *rest)
547
+ value = @native.get(key)
548
+ return value if rest.empty? || value.nil?
549
+
550
+ unless value.respond_to?(:dig)
551
+ raise TypeError, "#{value.class} does not have #dig method"
552
+ end
553
+
554
+ value.dig(*rest)
555
+ end
556
+
557
+ # Reads +keys+ as a single bounded batch, returning a +Hash+ of only the
558
+ # keys that are present (mirrors +Hash#slice+). Absent keys are omitted.
559
+ #
560
+ # @param keys [Array<String>] the keys to read
561
+ # @return [Hash{String => Object}] present keys mapped to their values
562
+ def slice(*keys)
563
+ result = {}
564
+ keys.zip(get_many(keys)) do |key, value|
565
+ result[key] = value unless value.nil?
566
+ end
567
+ result
568
+ end
569
+
570
+ # Reads +keys+ as a single bounded batch, requiring every key to be present
571
+ # (mirrors +Hash#fetch_values+). Without a block, a missing key raises
572
+ # {KeyError}; with a block, the block is called with each missing key and
573
+ # its result substituted.
574
+ #
575
+ # @param keys [Array<String>] the keys to read, in order
576
+ # @yieldparam key [String] called for each absent key
577
+ # @return [Array<Object>] one value per key, in order
578
+ # @raise [KeyError] when a key is absent and no block is given
579
+ def fetch_values(*keys, &block)
580
+ keys.zip(get_many(keys)).map do |key, value|
581
+ next value unless value.nil?
582
+ next block.call(key) if block
583
+
584
+ raise KeyError.new("key not found: #{key.inspect}", key: key, receiver: self)
585
+ end
586
+ end
587
+
588
+ private
589
+
590
+ def traverse(direction)
591
+ return enum_for(:traverse, direction) unless block_given?
592
+
593
+ # Yield the [key, value] pair as a single Array, matching Hash#each_pair:
594
+ # a two-parameter block auto-splats it (|k, v|), a one-parameter block
595
+ # receives the pair (|pair|), and the no-block Enumerator yields pairs.
596
+ scan_each(direction) { |pair| yield pair }
597
+ end
598
+
599
+ def traverse_keys(direction)
600
+ return enum_for(:traverse_keys, direction) unless block_given?
601
+
602
+ scan_each(direction, :keys) { |key| yield key }
603
+ end
604
+
605
+ def traverse_values(direction)
606
+ return enum_for(:traverse_values, direction) unless block_given?
607
+
608
+ scan_each(direction) { |entry| yield entry[1] }
609
+ end
610
+ end
611
+
612
+ # A deque keyed-state handle.
613
+ #
614
+ # Traversal is explicit: {#each}/{#reverse_each} yield single elements over a
615
+ # native scan, closing the scan via `ensure`. No aggregate-mixin methods are
616
+ # provided.
617
+ class DequeState
618
+ include State::Scanning
619
+
620
+ # @param native [Prosody::NativeJsonDequeState, Prosody::NativeMessageDequeState] the native handle
621
+ def initialize(native)
622
+ @native = native
623
+ end
624
+
625
+ # Appends an element at the back.
626
+ #
627
+ # @param value [Object] the element (JSON, or a message)
628
+ # @return [void]
629
+ # @raise [NullValueError] if `value` is `nil`
630
+ def push(value) = @native.push_back(value)
631
+
632
+ # Prepends an element at the front.
633
+ #
634
+ # @param value [Object] the element (JSON, or a message)
635
+ # @return [void]
636
+ # @raise [NullValueError] if `value` is `nil`
637
+ def unshift(value) = @native.push_front(value)
638
+
639
+ # Removes and returns the back element.
640
+ #
641
+ # @return [Object, nil] the removed element, or `nil` when empty
642
+ def pop = @native.pop_back
643
+
644
+ # Removes and returns the front element.
645
+ #
646
+ # @return [Object, nil] the removed element, or `nil` when empty
647
+ def shift = @native.pop_front
648
+
649
+ # The number of live elements.
650
+ #
651
+ # @return [Integer]
652
+ def length = @native.len
653
+
654
+ alias_method :size, :length
655
+
656
+ # Whether the deque holds no live elements.
657
+ #
658
+ # @return [Boolean]
659
+ def empty? = @native.is_empty
660
+
661
+ # Removes every element.
662
+ #
663
+ # @return [void]
664
+ def clear = @native.clear
665
+
666
+ # Durably commits the buffered operations mid-handler.
667
+ #
668
+ # @return [nil] the erased FFI seam drops the applied/no-op outcome
669
+ def commit = @native.commit
670
+
671
+ # Discards the buffered uncommitted operations.
672
+ #
673
+ # @return [nil]
674
+ def rollback = @native.rollback
675
+
676
+ # Reads the element at `index`, resolving negatives Array-style (mirrors
677
+ # +Array#[]+'s read domain, without the indexer). A non-negative index
678
+ # reads from the front; `-1` is the back element, `-n` the nth from the end.
679
+ # `-1` fast-paths through {#last} (no length read); other negatives resolve
680
+ # against the current length (one length read + one element read),
681
+ # consistent because the deque has a single writer per attempt.
682
+ #
683
+ # @param index [Integer] the position (negative counts from the back)
684
+ # @return [Object, nil] the element, or `nil` outside the bounds
685
+ # @raise [TransientStateError] if `index` is not an Integer
686
+ def get(index)
687
+ unless index.is_a?(Integer)
688
+ raise TransientStateError, "get: index must be an Integer, got #{index.inspect}"
689
+ end
690
+ index.negative? ? at_negative(index) : @native.get(index)
691
+ end
692
+
693
+ # Traverses the live elements in index order.
694
+ #
695
+ # Without a block, returns an {Enumerator} over the native scan. Each step
696
+ # fiber-yields; the scan is closed via `ensure` on stop or exception.
697
+ #
698
+ # @yieldparam element [Object]
699
+ # @return [Enumerator, void]
700
+ def each(&block) = traverse(:forward, &block)
701
+
702
+ # Traverses the live elements in reverse index order.
703
+ #
704
+ # @yieldparam element [Object]
705
+ # @return [Enumerator, void]
706
+ def reverse_each(&block) = traverse(:backward, &block)
707
+
708
+ # --- idiomatic Array-style conveniences -----------------------------
709
+ # Composed from the canonical ops above; bounded reads only (no +to_a+,
710
+ # +map+, +sort+, or +Enumerable+ that would materialize the whole deque).
711
+ #
712
+ # Deliberately NOT provided: +[]+ and +at+. This is a remote deque; +get+
713
+ # and +fetch+ accept a single +Integer+ index (negatives resolve from the
714
+ # back, Array-style), but wearing +Array+'s +[]+/+at+ would invite a range
715
+ # read (+deque[0..2]+) that cannot be honored. Use the explicit {#get}, or
716
+ # {#first}/{#last} for the ends.
717
+
718
+ # Prepends +value+, returning +self+ for chaining (mirrors +Array#prepend+).
719
+ # A wrapper, not an alias: the native write returns +nil+.
720
+ #
721
+ # @param value [Object]
722
+ # @return [self]
723
+ def prepend(value)
724
+ unshift(value)
725
+ self
726
+ end
727
+
728
+ # Appends +value+, returning +self+ for chaining (mirrors +Array#append+).
729
+ # A wrapper, not an alias: the native write returns +nil+.
730
+ #
731
+ # @param value [Object]
732
+ # @return [self]
733
+ def append(value)
734
+ push(value)
735
+ self
736
+ end
737
+
738
+ # Appends +value+ at the back and returns +self+ for chaining
739
+ # (mirrors +Array#<<+).
740
+ #
741
+ # @param value [Object]
742
+ # @return [self]
743
+ def <<(value)
744
+ push(value)
745
+ self
746
+ end
747
+
748
+ # The front element, or +nil+ when empty (mirrors +Array#first+). An
749
+ # endpoint-slot read in one round trip (no length read). Under a TTL an
750
+ # expired front slot yields +nil+ even when live interior elements remain —
751
+ # a peek never searches inward.
752
+ #
753
+ # @return [Object, nil]
754
+ def first = @native.peek_front
755
+
756
+ # The back element, or +nil+ when empty (mirrors +Array#last+). An
757
+ # endpoint-slot read in one round trip (no length read); same TTL-hole
758
+ # semantics as {#first}.
759
+ #
760
+ # @return [Object, nil]
761
+ def last = @native.peek_back
762
+
763
+ # Reads the element at +index+, raising or defaulting when out of range
764
+ # (mirrors +Array#fetch+). A +nil+ result is unambiguously "out of range"
765
+ # under the null ban. Negatives resolve Array-style like {#get} — +-1+ is
766
+ # the back element, +-n+ the nth from the end; a fractional or non-Integer
767
+ # index is a caller mistake, rejected {TransientStateError}.
768
+ #
769
+ # @param index [Integer] the position (negative counts from the back)
770
+ # @param default [Object] returned when +index+ is out of range
771
+ # @yieldparam index [Integer] called (instead of +default+) when out of range
772
+ # @return [Object]
773
+ # @raise [IndexError] when out of range and no default or block is given
774
+ # @raise [TransientStateError] if +index+ is not an Integer
775
+ def fetch(index, *default, &block)
776
+ if default.length > 1
777
+ raise ArgumentError, "wrong number of arguments (given #{default.length + 1}, expected 1..2)"
778
+ end
779
+ unless index.is_a?(Integer)
780
+ raise TransientStateError, "fetch: index must be an Integer, got #{index.inspect}"
781
+ end
782
+ warn "warning: block supersedes default value argument" if block && !default.empty?
783
+
784
+ value = index.negative? ? at_negative(index) : @native.get(index)
785
+ return value unless value.nil?
786
+ return block.call(index) if block
787
+ return default.first unless default.empty?
788
+
789
+ raise IndexError, "index #{index} outside deque bounds"
790
+ end
791
+
792
+ private
793
+
794
+ # Resolves a negative Array-style index against the current length: +-1+
795
+ # fast-paths through {#last} (no length read), other negatives read the
796
+ # length and index from the front. Returns +nil+ when the index resolves
797
+ # before the front (past the far end of the deque).
798
+ def at_negative(index)
799
+ return @native.peek_back if index == -1
800
+
801
+ resolved = @native.len + index
802
+ resolved.negative? ? nil : @native.get(resolved)
803
+ end
804
+
805
+ def traverse(direction)
806
+ return enum_for(:traverse, direction) unless block_given?
807
+
808
+ scan_each(direction) { |item| yield item }
809
+ end
810
+ end
811
+
812
+ # Reopens the native context class to add keyed-state vending.
813
+ class Context
814
+ include State::Vending
815
+ end
816
+ end