prosody 0.2.1-aarch64-linux → 0.4.0-aarch64-linux

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.
@@ -0,0 +1,693 @@
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
+ StateDefinition = Data.define(:name, :kind, :payload, :ttl_seconds, :read_uncommitted, :keyset_limit, :capacity) do
44
+ # Serializes this definition into the native-registration hash, omitting
45
+ # unset optionals so they fall back to the core defaults.
46
+ #
47
+ # @return [Hash] the registration hash for the native layer
48
+ def to_state_config
49
+ config = {name: name, kind: kind, payload: payload}
50
+ config[:ttl_seconds] = ttl_seconds unless ttl_seconds.nil?
51
+ config[:read_uncommitted] = read_uncommitted unless read_uncommitted.nil?
52
+ config[:keyset_limit] = keyset_limit unless keyset_limit.nil?
53
+ config[:capacity] = capacity unless capacity.nil?
54
+ config
55
+ end
56
+ end
57
+
58
+ # Defines a single-value JSON collection.
59
+ #
60
+ # @param name [#to_s] the collection name (unique within the client)
61
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
62
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
63
+ # @return [StateDefinition] a frozen definition
64
+ def self.value(name, ttl: nil, read_uncommitted: nil)
65
+ StateDefinition.new(name: name.to_s, kind: "value", payload: "json",
66
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, keyset_limit: nil, capacity: nil)
67
+ end
68
+
69
+ # Defines a `String`-keyed ordered map JSON collection.
70
+ #
71
+ # @param name [#to_s] the collection name (unique within the client)
72
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
73
+ # @param keyset_limit [Integer, nil] optional map-only keyset bound (`0..=4096`)
74
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
75
+ # @return [StateDefinition] a frozen definition
76
+ def self.map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil)
77
+ StateDefinition.new(name: name.to_s, kind: "map", payload: "json",
78
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, keyset_limit: keyset_limit, capacity: nil)
79
+ end
80
+
81
+ # Defines a deque JSON collection.
82
+ #
83
+ # @param name [#to_s] the collection name (unique within the client)
84
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
85
+ # @param capacity [Integer, nil] optional window bound (at least 1); the
86
+ # deque keeps at most this many slots, enforced lazily on push. Runtime-only
87
+ # and mutable across deploys, never persisted (see {DequeState#push}).
88
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
89
+ # @return [StateDefinition] a frozen definition
90
+ def self.deque(name, ttl: nil, capacity: nil, read_uncommitted: nil)
91
+ StateDefinition.new(name: name.to_s, kind: "deque", payload: "json",
92
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, keyset_limit: nil, capacity: capacity)
93
+ end
94
+
95
+ # Defines a single-value Kafka-message collection (items are full messages).
96
+ #
97
+ # @param name [#to_s] the collection name (unique within the client)
98
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
99
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
100
+ # @return [StateDefinition] a frozen definition
101
+ def self.message_value(name, ttl: nil, read_uncommitted: nil)
102
+ StateDefinition.new(name: name.to_s, kind: "value", payload: "message",
103
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, keyset_limit: nil, capacity: nil)
104
+ end
105
+
106
+ # Defines a `String`-keyed ordered map Kafka-message collection.
107
+ #
108
+ # @param name [#to_s] the collection name (unique within the client)
109
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
110
+ # @param keyset_limit [Integer, nil] optional map-only keyset bound (`0..=4096`)
111
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
112
+ # @return [StateDefinition] a frozen definition
113
+ def self.message_map(name, ttl: nil, keyset_limit: nil, read_uncommitted: nil)
114
+ StateDefinition.new(name: name.to_s, kind: "map", payload: "message",
115
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, keyset_limit: keyset_limit, capacity: nil)
116
+ end
117
+
118
+ # Defines a deque Kafka-message collection.
119
+ #
120
+ # @param name [#to_s] the collection name (unique within the client)
121
+ # @param ttl [Integer, nil] optional per-write TTL in whole seconds
122
+ # @param capacity [Integer, nil] optional window bound (at least 1); the
123
+ # deque keeps at most this many slots, enforced lazily on push. Runtime-only
124
+ # and mutable across deploys, never persisted (see {DequeState#push}).
125
+ # @param read_uncommitted [Boolean, nil] optional opt-out of transactional staging
126
+ # @return [StateDefinition] a frozen definition
127
+ def self.message_deque(name, ttl: nil, capacity: nil, read_uncommitted: nil)
128
+ StateDefinition.new(name: name.to_s, kind: "deque", payload: "message",
129
+ ttl_seconds: ttl, read_uncommitted: read_uncommitted, keyset_limit: nil, capacity: capacity)
130
+ end
131
+
132
+ # Internal routing tables shared by the state wrappers.
133
+ module State
134
+ # Maps a definition's `[kind, payload]` to the native vend method and the
135
+ # public wrapper class that wraps the vended native handle.
136
+ VEND = {
137
+ %w[value json] => [:value_state, :ValueState],
138
+ %w[map json] => [:map_state, :MapState],
139
+ %w[deque json] => [:deque_state, :DequeState],
140
+ %w[value message] => [:message_value_state, :ValueState],
141
+ %w[map message] => [:message_map_state, :MapState],
142
+ %w[deque message] => [:message_deque_state, :DequeState]
143
+ }.freeze
144
+
145
+ # Adds keyed-state vending to the native context. Included into
146
+ # {Prosody::Context}; kept as a module so the routing can be exercised
147
+ # against a stand-in receiver.
148
+ module Vending
149
+ # Vends the typed keyed-state handle for `definition`.
150
+ #
151
+ # Handles are cached per context by kind, payload, and name, so repeated
152
+ # vends within one handler invocation return the same wrapper.
153
+ #
154
+ # @param definition [StateDefinition] a frozen collection definition
155
+ # @return [ValueState, MapState, DequeState] the typed handle
156
+ # @raise [TransientStateError] if the definition's kind/payload is unknown
157
+ # @raise [PermanentStateError] if the collection name is unregistered or
158
+ # its registered identity mismatches
159
+ def state(definition)
160
+ cache = (@state_handles ||= {})
161
+ cache_key = "#{definition.kind}:#{definition.payload}:#{definition.name}"
162
+ return cache[cache_key] if cache.key?(cache_key)
163
+
164
+ vend_method, wrapper = VEND.fetch([definition.kind, definition.payload]) do
165
+ raise TransientStateError,
166
+ "state: unknown collection kind/payload #{[definition.kind, definition.payload].inspect}"
167
+ end
168
+ native = public_send(vend_method, definition.name)
169
+ cache[cache_key] = Prosody.const_get(wrapper).new(native)
170
+ end
171
+ end
172
+
173
+ # Shared cursor-driving for the explicit-traversal handles. Folds the
174
+ # identical native-scan open/close/exhaustion loop; each handle supplies
175
+ # only the per-item yield shape through the block. Kept private (mixed into
176
+ # the handle classes) since it is not part of the public surface.
177
+ module Scanning
178
+ private
179
+
180
+ # Opens a native scan in `direction`, yields each item, and closes the
181
+ # scan via `ensure` on stop or exception. Direction validity is enforced
182
+ # by the native layer (an invalid token is rejected transient there); the
183
+ # public traversal methods only ever pass `:forward`/`:backward`. `opener`
184
+ # selects the native cursor seam — the default `:scan` yields values (or
185
+ # `[key, value]` pairs), `:keys` yields bare map keys.
186
+ def scan_each(direction, opener = :scan)
187
+ scan = @native.public_send(opener, direction.to_s)
188
+ begin
189
+ # `nil` is the exhaustion sentinel (unambiguous under the null ban);
190
+ # terminate on it explicitly rather than on falsiness, so a legal
191
+ # stored `false` (or a `[key, false]` pair, always a truthy Array)
192
+ # does not stop iteration and drop the tail after it.
193
+ until (item = scan.next).nil?
194
+ yield item
195
+ end
196
+ ensure
197
+ scan.close
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ # A single-value keyed-state handle.
204
+ #
205
+ # Reads return the stored JSON value (or a {Prosody::Message} for message
206
+ # collections), or `nil` when the value is absent. Writes are buffered and
207
+ # made durable by {#commit}. All operations are fiber-yield async: they look
208
+ # blocking but never block the thread.
209
+ class ValueState
210
+ # @param native [Prosody::NativeValueState] the vended native handle
211
+ def initialize(native)
212
+ @native = native
213
+ end
214
+
215
+ # Reads the current value.
216
+ #
217
+ # @return [Object, nil] the stored value, or `nil` when absent
218
+ def get = @native.get
219
+
220
+ # Buffers a write of the value.
221
+ #
222
+ # @param value [Object] the value to store (JSON, or a message)
223
+ # @return [void]
224
+ # @raise [NullValueError] if `value` is `nil` (use {#clear} to delete)
225
+ def set(value) = @native.set(value)
226
+
227
+ # Buffers a clear of the value.
228
+ #
229
+ # @return [void]
230
+ def clear = @native.clear
231
+
232
+ # Durably commits the buffered operations mid-handler.
233
+ #
234
+ # @return [nil] the erased FFI seam drops the applied/no-op outcome
235
+ def commit = @native.commit
236
+
237
+ # Discards the buffered uncommitted operations.
238
+ #
239
+ # @return [nil]
240
+ def rollback = @native.rollback
241
+
242
+ # Reads the current value. Idiomatic alias of {#get}.
243
+ #
244
+ # @return [Object, nil]
245
+ alias_method :value, :get
246
+
247
+ # Buffers a write of the value. Idiomatic alias of {#set}. As with any Ruby
248
+ # writer, `state.value = x` evaluates to `x` regardless of the return.
249
+ #
250
+ # @param value [Object]
251
+ # @return [void]
252
+ alias_method :value=, :set
253
+ end
254
+
255
+ # A `String`-keyed ordered-map keyed-state handle.
256
+ #
257
+ # Traversal is explicit: {#each_pair}/{#reverse_each_pair} yield `key, value`
258
+ # pairs over a native scan, closing the scan via `ensure`. No aggregate-mixin
259
+ # methods are provided — they would silently materialize an unbounded remote
260
+ # collection.
261
+ class MapState
262
+ include State::Scanning
263
+
264
+ # @param native [Prosody::NativeMapState] the vended native handle
265
+ def initialize(native)
266
+ @native = native
267
+ end
268
+
269
+ # Reads the value for `key`.
270
+ #
271
+ # @param key [String] the map key
272
+ # @return [Object, nil] the value, or `nil` when the key is absent
273
+ def get(key) = @native.get(key)
274
+
275
+ # Reads several keys in a single isolated batch.
276
+ #
277
+ # @param keys [Array<String>] the keys to read, in order
278
+ # @return [Array<Object, nil>] one result per input key; `nil` for absent keys
279
+ def get_many(keys) = @native.get_many(keys)
280
+
281
+ # Inserts or overwrites `key`.
282
+ #
283
+ # @param key [String] the map key
284
+ # @param value [Object] the value to store (JSON, or a message)
285
+ # @return [void]
286
+ # @raise [NullValueError] if `value` is `nil` (use {#delete} to remove)
287
+ def set(key, value) = @native.set(key, value)
288
+
289
+ # Removes `key`.
290
+ #
291
+ # Documented divergence from `Hash#delete`: this returns `nil`, never the
292
+ # removed value (the erased FFI seam does not surface it).
293
+ #
294
+ # @param key [String] the map key
295
+ # @return [nil]
296
+ def delete(key)
297
+ @native.remove(key)
298
+ nil
299
+ end
300
+
301
+ # Removes every entry.
302
+ #
303
+ # @return [void]
304
+ def clear = @native.clear
305
+
306
+ # Durably commits the buffered operations mid-handler.
307
+ #
308
+ # @return [nil] the erased FFI seam drops the applied/no-op outcome
309
+ def commit = @native.commit
310
+
311
+ # Discards the buffered uncommitted operations.
312
+ #
313
+ # @return [nil]
314
+ def rollback = @native.rollback
315
+
316
+ # Traverses the live entries in key order, yielding `key, value`.
317
+ #
318
+ # Without a block, returns an {Enumerator} over the native scan. Each step
319
+ # fiber-yields; the scan is closed via `ensure` on stop or exception. The
320
+ # enumerator is valid only within the current handler invocation.
321
+ #
322
+ # @yieldparam key [String]
323
+ # @yieldparam value [Object]
324
+ # @return [Enumerator, void]
325
+ def each_pair(&block) = traverse(:forward, &block)
326
+
327
+ # Traverses the live entries in reverse key order, yielding `key, value`.
328
+ #
329
+ # @yieldparam key [String]
330
+ # @yieldparam value [Object]
331
+ # @return [Enumerator, void]
332
+ def reverse_each_pair(&block) = traverse(:backward, &block)
333
+
334
+ # Traverses the live keys in key order, yielding each key (mirrors
335
+ # +Hash#each_key+). The key scan skips value decode and the resolver — a
336
+ # message-backed map yields keys with zero Kafka fetches, though not
337
+ # zero-I/O. Without a block, returns a demand-driven {Enumerator}; there is
338
+ # deliberately no eager +keys+ array (it would materialize the whole remote
339
+ # keyset). Mirrors {#each_pair}'s block-form return (+nil+), not stdlib's
340
+ # +self+, for in-repo sibling consistency.
341
+ #
342
+ # @yieldparam key [String]
343
+ # @return [Enumerator, void]
344
+ def each_key(&block) = traverse_keys(:forward, &block)
345
+
346
+ # Traverses the live keys in reverse key order, yielding each key.
347
+ #
348
+ # @yieldparam key [String]
349
+ # @return [Enumerator, void]
350
+ def reverse_each_key(&block) = traverse_keys(:backward, &block)
351
+
352
+ # --- idiomatic Hash-style aliases and conveniences ------------------
353
+ # Each is composed from the canonical ops above and adds no capability
354
+ # the naming matrix lacks. Bounded reads only: there is deliberately no
355
+ # +keys+/+values+/+to_h+/+count+ or +Enumerable+, which would materialize
356
+ # the whole (potentially unbounded) remote collection.
357
+
358
+ # Reads +key+. Idiomatic alias of {#get} (mirrors +Hash#[]+).
359
+ alias_method :[], :get
360
+
361
+ # Writes +key+. Idiomatic alias of {#set} (mirrors +Hash#[]=+). As with any
362
+ # Ruby +[]=+, `map[key] = value` evaluates to +value+ regardless of return.
363
+ alias_method :[]=, :set
364
+
365
+ # Writes +key+, returning the stored +value+ (mirrors +Hash#store+). A
366
+ # wrapper, not an alias: unlike +[]=+, +store+ is called normally, so its
367
+ # return is observed — and the native write returns +nil+.
368
+ #
369
+ # @param key [String]
370
+ # @param value [Object]
371
+ # @return [Object] the stored +value+
372
+ def store(key, value)
373
+ set(key, value)
374
+ value
375
+ end
376
+
377
+ # Traverses live entries in key order. Idiomatic alias of {#each_pair}
378
+ # (mirrors +Hash#each+).
379
+ alias_method :each, :each_pair
380
+
381
+ # Reads several keys positionally (mirrors +Hash#values_at+).
382
+ #
383
+ # @param keys [Array<String>] the keys to read
384
+ # @return [Array<Object, nil>] one result per key; +nil+ for absent keys
385
+ def values_at(*keys) = get_many(keys)
386
+
387
+ # Reads +key+, raising or defaulting when absent (mirrors +Hash#fetch+).
388
+ # Performs a single read; a +nil+ result is unambiguously "absent" under
389
+ # the null ban.
390
+ #
391
+ # @param key [String]
392
+ # @param default [Object] returned when +key+ is absent
393
+ # @yieldparam key [String] called (instead of +default+) when +key+ is absent
394
+ # @return [Object]
395
+ # @raise [KeyError] when +key+ is absent and no default or block is given
396
+ def fetch(key, *default, &block)
397
+ if default.length > 1
398
+ raise ArgumentError, "wrong number of arguments (given #{default.length + 1}, expected 1..2)"
399
+ end
400
+ warn "warning: block supersedes default value argument" if block && !default.empty?
401
+
402
+ value = @native.get(key)
403
+ return value unless value.nil?
404
+ return block.call(key) if block
405
+ return default.first unless default.empty?
406
+
407
+ raise KeyError.new("key not found: #{key.inspect}", key: key, receiver: self)
408
+ end
409
+
410
+ # Whether +key+ has a live value (mirrors +Hash#key?+). A presence check:
411
+ # no value decode and no resolver run (not no-I/O). A message-backed map
412
+ # answers presence with zero Kafka fetches — +true+ even for a
413
+ # present-but-unfetchable cell — though a cache miss may still touch the
414
+ # store.
415
+ #
416
+ # @param key [String]
417
+ # @return [Boolean]
418
+ def key?(key) = @native.contains_key(key)
419
+ alias_method :has_key?, :key?
420
+ alias_method :include?, :key?
421
+ alias_method :member?, :key?
422
+
423
+ # Reads +key+ and digs into the nested value (mirrors +Hash#dig+). A single
424
+ # bounded read; digging continues in the returned local value.
425
+ #
426
+ # @param key [String]
427
+ # @return [Object, nil]
428
+ # @raise [TypeError] if a nested value does not respond to +dig+
429
+ def dig(key, *rest)
430
+ value = @native.get(key)
431
+ return value if rest.empty? || value.nil?
432
+
433
+ unless value.respond_to?(:dig)
434
+ raise TypeError, "#{value.class} does not have #dig method"
435
+ end
436
+
437
+ value.dig(*rest)
438
+ end
439
+
440
+ # Reads +keys+ as a single bounded batch, returning a +Hash+ of only the
441
+ # keys that are present (mirrors +Hash#slice+). Absent keys are omitted.
442
+ #
443
+ # @param keys [Array<String>] the keys to read
444
+ # @return [Hash{String => Object}] present keys mapped to their values
445
+ def slice(*keys)
446
+ result = {}
447
+ keys.zip(get_many(keys)) do |key, value|
448
+ result[key] = value unless value.nil?
449
+ end
450
+ result
451
+ end
452
+
453
+ # Reads +keys+ as a single bounded batch, requiring every key to be present
454
+ # (mirrors +Hash#fetch_values+). Without a block, a missing key raises
455
+ # {KeyError}; with a block, the block is called with each missing key and
456
+ # its result substituted.
457
+ #
458
+ # @param keys [Array<String>] the keys to read, in order
459
+ # @yieldparam key [String] called for each absent key
460
+ # @return [Array<Object>] one value per key, in order
461
+ # @raise [KeyError] when a key is absent and no block is given
462
+ def fetch_values(*keys, &block)
463
+ keys.zip(get_many(keys)).map do |key, value|
464
+ next value unless value.nil?
465
+ next block.call(key) if block
466
+
467
+ raise KeyError.new("key not found: #{key.inspect}", key: key, receiver: self)
468
+ end
469
+ end
470
+
471
+ private
472
+
473
+ def traverse(direction)
474
+ return enum_for(:traverse, direction) unless block_given?
475
+
476
+ # Yield the [key, value] pair as a single Array, matching Hash#each_pair:
477
+ # a two-parameter block auto-splats it (|k, v|), a one-parameter block
478
+ # receives the pair (|pair|), and the no-block Enumerator yields pairs.
479
+ scan_each(direction) { |pair| yield pair }
480
+ end
481
+
482
+ def traverse_keys(direction)
483
+ return enum_for(:traverse_keys, direction) unless block_given?
484
+
485
+ scan_each(direction, :keys) { |key| yield key }
486
+ end
487
+ end
488
+
489
+ # A deque keyed-state handle.
490
+ #
491
+ # Traversal is explicit: {#each}/{#reverse_each} yield single elements over a
492
+ # native scan, closing the scan via `ensure`. No aggregate-mixin methods are
493
+ # provided.
494
+ class DequeState
495
+ include State::Scanning
496
+
497
+ # @param native [Prosody::NativeDequeState] the vended native handle
498
+ def initialize(native)
499
+ @native = native
500
+ end
501
+
502
+ # Appends an element at the back.
503
+ #
504
+ # @param value [Object] the element (JSON, or a message)
505
+ # @return [void]
506
+ # @raise [NullValueError] if `value` is `nil`
507
+ def push(value) = @native.push_back(value)
508
+
509
+ # Prepends an element at the front.
510
+ #
511
+ # @param value [Object] the element (JSON, or a message)
512
+ # @return [void]
513
+ # @raise [NullValueError] if `value` is `nil`
514
+ def unshift(value) = @native.push_front(value)
515
+
516
+ # Removes and returns the back element.
517
+ #
518
+ # @return [Object, nil] the removed element, or `nil` when empty
519
+ def pop = @native.pop_back
520
+
521
+ # Removes and returns the front element.
522
+ #
523
+ # @return [Object, nil] the removed element, or `nil` when empty
524
+ def shift = @native.pop_front
525
+
526
+ # The number of live elements.
527
+ #
528
+ # @return [Integer]
529
+ def length = @native.len
530
+
531
+ alias_method :size, :length
532
+
533
+ # Whether the deque holds no live elements.
534
+ #
535
+ # @return [Boolean]
536
+ def empty? = @native.is_empty
537
+
538
+ # Removes every element.
539
+ #
540
+ # @return [void]
541
+ def clear = @native.clear
542
+
543
+ # Durably commits the buffered operations mid-handler.
544
+ #
545
+ # @return [nil] the erased FFI seam drops the applied/no-op outcome
546
+ def commit = @native.commit
547
+
548
+ # Discards the buffered uncommitted operations.
549
+ #
550
+ # @return [nil]
551
+ def rollback = @native.rollback
552
+
553
+ # Reads the element at `index`, resolving negatives Array-style (mirrors
554
+ # +Array#[]+'s read domain, without the indexer). A non-negative index
555
+ # reads from the front; `-1` is the back element, `-n` the nth from the end.
556
+ # `-1` fast-paths through {#last} (no length read); other negatives resolve
557
+ # against the current length (one length read + one element read),
558
+ # consistent because the deque has a single writer per attempt.
559
+ #
560
+ # @param index [Integer] the position (negative counts from the back)
561
+ # @return [Object, nil] the element, or `nil` outside the bounds
562
+ # @raise [TransientStateError] if `index` is not an Integer
563
+ def get(index)
564
+ unless index.is_a?(Integer)
565
+ raise TransientStateError, "get: index must be an Integer, got #{index.inspect}"
566
+ end
567
+ index.negative? ? at_negative(index) : @native.get(index)
568
+ end
569
+
570
+ # Traverses the live elements in index order.
571
+ #
572
+ # Without a block, returns an {Enumerator} over the native scan. Each step
573
+ # fiber-yields; the scan is closed via `ensure` on stop or exception.
574
+ #
575
+ # @yieldparam element [Object]
576
+ # @return [Enumerator, void]
577
+ def each(&block) = traverse(:forward, &block)
578
+
579
+ # Traverses the live elements in reverse index order.
580
+ #
581
+ # @yieldparam element [Object]
582
+ # @return [Enumerator, void]
583
+ def reverse_each(&block) = traverse(:backward, &block)
584
+
585
+ # --- idiomatic Array-style conveniences -----------------------------
586
+ # Composed from the canonical ops above; bounded reads only (no +to_a+,
587
+ # +map+, +sort+, or +Enumerable+ that would materialize the whole deque).
588
+ #
589
+ # Deliberately NOT provided: +[]+ and +at+. This is a remote deque; +get+
590
+ # and +fetch+ accept a single +Integer+ index (negatives resolve from the
591
+ # back, Array-style), but wearing +Array+'s +[]+/+at+ would invite a range
592
+ # read (+deque[0..2]+) that cannot be honored. Use the explicit {#get}, or
593
+ # {#first}/{#last} for the ends.
594
+
595
+ # Prepends +value+, returning +self+ for chaining (mirrors +Array#prepend+).
596
+ # A wrapper, not an alias: the native write returns +nil+.
597
+ #
598
+ # @param value [Object]
599
+ # @return [self]
600
+ def prepend(value)
601
+ unshift(value)
602
+ self
603
+ end
604
+
605
+ # Appends +value+, returning +self+ for chaining (mirrors +Array#append+).
606
+ # A wrapper, not an alias: the native write returns +nil+.
607
+ #
608
+ # @param value [Object]
609
+ # @return [self]
610
+ def append(value)
611
+ push(value)
612
+ self
613
+ end
614
+
615
+ # Appends +value+ at the back and returns +self+ for chaining
616
+ # (mirrors +Array#<<+).
617
+ #
618
+ # @param value [Object]
619
+ # @return [self]
620
+ def <<(value)
621
+ push(value)
622
+ self
623
+ end
624
+
625
+ # The front element, or +nil+ when empty (mirrors +Array#first+). An
626
+ # endpoint-slot read in one round trip (no length read). Under a TTL an
627
+ # expired front slot yields +nil+ even when live interior elements remain —
628
+ # a peek never searches inward.
629
+ #
630
+ # @return [Object, nil]
631
+ def first = @native.peek_front
632
+
633
+ # The back element, or +nil+ when empty (mirrors +Array#last+). An
634
+ # endpoint-slot read in one round trip (no length read); same TTL-hole
635
+ # semantics as {#first}.
636
+ #
637
+ # @return [Object, nil]
638
+ def last = @native.peek_back
639
+
640
+ # Reads the element at +index+, raising or defaulting when out of range
641
+ # (mirrors +Array#fetch+). A +nil+ result is unambiguously "out of range"
642
+ # under the null ban. Negatives resolve Array-style like {#get} — +-1+ is
643
+ # the back element, +-n+ the nth from the end; a fractional or non-Integer
644
+ # index is a caller mistake, rejected {TransientStateError}.
645
+ #
646
+ # @param index [Integer] the position (negative counts from the back)
647
+ # @param default [Object] returned when +index+ is out of range
648
+ # @yieldparam index [Integer] called (instead of +default+) when out of range
649
+ # @return [Object]
650
+ # @raise [IndexError] when out of range and no default or block is given
651
+ # @raise [TransientStateError] if +index+ is not an Integer
652
+ def fetch(index, *default, &block)
653
+ if default.length > 1
654
+ raise ArgumentError, "wrong number of arguments (given #{default.length + 1}, expected 1..2)"
655
+ end
656
+ unless index.is_a?(Integer)
657
+ raise TransientStateError, "fetch: index must be an Integer, got #{index.inspect}"
658
+ end
659
+ warn "warning: block supersedes default value argument" if block && !default.empty?
660
+
661
+ value = index.negative? ? at_negative(index) : @native.get(index)
662
+ return value unless value.nil?
663
+ return block.call(index) if block
664
+ return default.first unless default.empty?
665
+
666
+ raise IndexError, "index #{index} outside deque bounds"
667
+ end
668
+
669
+ private
670
+
671
+ # Resolves a negative Array-style index against the current length: +-1+
672
+ # fast-paths through {#last} (no length read), other negatives read the
673
+ # length and index from the front. Returns +nil+ when the index resolves
674
+ # before the front (past the far end of the deque).
675
+ def at_negative(index)
676
+ return @native.peek_back if index == -1
677
+
678
+ resolved = @native.len + index
679
+ resolved.negative? ? nil : @native.get(resolved)
680
+ end
681
+
682
+ def traverse(direction)
683
+ return enum_for(:traverse, direction) unless block_given?
684
+
685
+ scan_each(direction) { |item| yield item }
686
+ end
687
+ end
688
+
689
+ # Reopens the native context class to add keyed-state vending.
690
+ class Context
691
+ include State::Vending
692
+ end
693
+ end
@@ -6,5 +6,5 @@ module Prosody
6
6
  # This version number follows semantic versioning and is used by the
7
7
  # gem system to identify the library version. It should be updated
8
8
  # according to semver guidelines when making releases.
9
- VERSION = "0.2.1"
9
+ VERSION = "0.4.0"
10
10
  end