cent 4.0.0 → 5.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.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Cent
6
+ # A request to a handler, as the application's block receives it.
7
+ #
8
+ # @!attribute [r] method
9
+ # @return [String] the HTTP method, upper case
10
+ # @!attribute [r] headers
11
+ # @return [Hash{String => String}] the headers, lowercase names: those
12
+ # Centrifugo passes on from the client's connection to a proxy, the client
13
+ # SDK's to an endpoint
14
+ # @!attribute [r] query
15
+ # @return [Hash{String => String}] the query parameters, the first value of
16
+ # each
17
+ # @!attribute [r] path
18
+ # @return [String] the path, as the server saw it
19
+ # @!attribute [r] raw
20
+ # @return [Object] the server's own form of the request: the Rack env, or
21
+ # what was given to {Handler#handle} - for what the core does not carry,
22
+ # such as the client's address or the session
23
+ class HTTPRequest < Data.define(:method, :headers, :query, :path, :raw); end
24
+
25
+ # A handler's answer: +[status, headers, body]+ with {#to_a}.
26
+ #
27
+ # @!attribute [r] status
28
+ # @return [Integer]
29
+ # @!attribute [r] headers
30
+ # @return [Hash{String => String}] lowercase names, as Rack 3 wants them
31
+ # @!attribute [r] body
32
+ # @return [String]
33
+ class HTTPResponse < Data.define(:status, :headers, :body)
34
+ # @return [Array] +[status, headers, body]+
35
+ def to_a = [status, headers, body]
36
+
37
+ # @api private
38
+ def self.text(status, message, extra = {})
39
+ new(status:, headers: { "content-type" => "text/plain; charset=utf-8", **extra }, body: message)
40
+ end
41
+
42
+ # @api private
43
+ REASONS = { 400 => "Bad Request", 403 => "Forbidden", 405 => "Method Not Allowed",
44
+ 500 => "Internal Server Error" }.freeze
45
+
46
+ # @api private
47
+ def self.status_text(status) = text(status, REASONS.fetch(status, status.to_s))
48
+
49
+ # @api private
50
+ def self.allow(methods) = text(405, "only #{methods} allowed", { "allow" => methods })
51
+
52
+ # @api private
53
+ def self.json(body, cache = "no-store")
54
+ new(status: 200, headers: { "content-type" => "application/json", "cache-control" => cache }, body:)
55
+ end
56
+ end
57
+
58
+ # The base of every handler - an endpoint for client SDKs, the JWKS endpoint,
59
+ # a proxy handler. Its framework-free core, {#handle}, turns a request into a
60
+ # response; {#call} serves it as a Rack app, which Rails, Sinatra, Hanami and
61
+ # every Rack server mount:
62
+ #
63
+ # mount handler, at: "/centrifugo/connect" # config/routes.rb
64
+ #
65
+ # Any other server calls {#handle}.
66
+ class Handler
67
+ # @return [Integer] the largest request body read, in bytes
68
+ attr_reader :max_body
69
+
70
+ # @api private
71
+ def initialize(max_body, on_error, &func)
72
+ raise ArgumentError, "#{self.class} takes a block" if func.nil? && !answers_itself?
73
+ raise ArgumentError, "on_error must respond to call" unless on_error.nil? || on_error.respond_to?(:call)
74
+ unless max_body.is_a?(Integer) && max_body.positive?
75
+ raise ArgumentError,
76
+ "max_body must be a positive number of bytes"
77
+ end
78
+
79
+ @max_body = max_body
80
+ @on_error = on_error
81
+ @func = func
82
+ end
83
+
84
+ # Answers a request.
85
+ #
86
+ # @param method [String]
87
+ # @param headers [Hash{String => String}, Array<Array(String, String)>]
88
+ # @param body [String]
89
+ # @param query [Hash{String => String}]
90
+ # @param path [String]
91
+ # @param raw [Object] passed on as {HTTPRequest#raw}: the server's own
92
+ # request object
93
+ # @return [HTTPResponse]
94
+ def handle(method, headers = {}, body = "", query: {}, path: "", raw: nil)
95
+ h = {}
96
+ headers.each do |k, v|
97
+ k = k.to_s.downcase
98
+ h[k] = h.key?(k) ? "#{h[k]}, #{v}" : v.to_s
99
+ end
100
+ req = HTTPRequest.new(method: method.to_s.upcase, headers: h.freeze, query: query.to_h.freeze, path:, raw:)
101
+ return failure(req, 400, ArgumentError.new("request body over #{max_body} bytes")) if body.bytesize > max_body
102
+
103
+ begun = start(req, body)
104
+ return begun if begun.is_a?(HTTPResponse)
105
+
106
+ begin
107
+ out = @func.call(*begun, req)
108
+ rescue StandardError => e
109
+ return finish(req, begun, nil, e)
110
+ end
111
+ finish(req, begun, out, nil)
112
+ end
113
+
114
+ # The handler as a Rack app.
115
+ #
116
+ # @param env [Hash] the Rack env
117
+ # @return [Array] +[status, headers, [body]]+
118
+ def call(env)
119
+ body = read_body(env)
120
+ headers = env.filter_map { |k, v| [k.delete_prefix("HTTP_").tr("_", "-"), v] if k.start_with?("HTTP_") }
121
+ headers << ["content-type", env["CONTENT_TYPE"]] if env["CONTENT_TYPE"]
122
+ headers << ["content-length", env["CONTENT_LENGTH"]] if env["CONTENT_LENGTH"]
123
+ query = {}
124
+ URI.decode_www_form(env["QUERY_STRING"].to_s).each { |k, v| query[k] ||= v }
125
+ resp = handle(env["REQUEST_METHOD"], headers, body, query:, path: env["PATH_INFO"].to_s, raw: env)
126
+ [resp.status, resp.headers.dup, [resp.body]]
127
+ rescue ArgumentError => e
128
+ # A malformed query string.
129
+ resp = failure(HTTPRequest.new(method: env["REQUEST_METHOD"].to_s, headers: {}, query: {}, path: "", raw: env),
130
+ 400, e)
131
+ [resp.status, resp.headers.dup, [resp.body]]
132
+ end
133
+
134
+ private
135
+
136
+ def answers_itself? = false
137
+
138
+ # The block's arguments before the request, or the response when the
139
+ # request is answered without calling it.
140
+ def start(req, body) = raise NotImplementedError
141
+
142
+ # The response to what the block returned or raised.
143
+ def finish(req, args, out, error) = raise NotImplementedError
144
+
145
+ # +status+, after telling on_error of +err+.
146
+ def failure(req, status, err)
147
+ @on_error&.call(req, err)
148
+ HTTPResponse.status_text(status)
149
+ end
150
+
151
+ def read_body(env)
152
+ input = env["rack.input"]
153
+ return "" if input.nil?
154
+
155
+ # A framework may have read it already: Rails does.
156
+ input.rewind if input.respond_to?(:rewind)
157
+ (input.read(max_body + 1) || "").b
158
+ end
159
+
160
+ # err, or its cause, when it is a klass.
161
+ def find(err, klass)
162
+ while err
163
+ return err if err.is_a?(klass)
164
+
165
+ err = err.cause
166
+ end
167
+ nil
168
+ end
169
+
170
+ def parse_json(req, body)
171
+ d = JSON.parse(body, allow_nan: false)
172
+ return failure(req, 400, ArgumentError.new("malformed request: not a JSON object")) unless d.is_a?(Hash)
173
+
174
+ d
175
+ rescue JSON::ParserError, EncodingError => e
176
+ failure(req, 400, ArgumentError.new("malformed request: #{e.message}"))
177
+ end
178
+ end
179
+ end
data/lib/cent/http.rb ADDED
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Cent
8
+ # The default HTTP transport: Net::HTTP connections kept alive in a pool, each
9
+ # request bounded by one deadline through Net::HTTP's public API: the time
10
+ # left as its open, read and write timeouts, and again as the read timeout
11
+ # before each chunk of the body. Those bound each read, so a reply's headers
12
+ # trickling in would outlast them: the Watchdog finishes a connection whose
13
+ # deadline passed.
14
+ #
15
+ # @api private
16
+ module HTTP
17
+ # Up to 256 idle connections to a host are kept, so that a burst does not
18
+ # open connections only to close them; idle ones are closed after 50
19
+ # seconds, before load balancers commonly close theirs.
20
+ MAX_IDLE = 256
21
+ IDLE_TIMEOUT = 50.0
22
+
23
+ # The body of an unexpected status is read up to MAX_API_ERROR_BYTES for a
24
+ # Centrifugo error in it, MAX_ERROR_BODY_BYTES of it kept for the HTTPError,
25
+ # and the rest drained up to MAX_DRAIN_BYTES, so that the connection is
26
+ # reused.
27
+ MAX_ERROR_BODY_BYTES = 512
28
+ MAX_API_ERROR_BYTES = 4 << 10
29
+ MAX_DRAIN_BYTES = 64 << 10
30
+
31
+ def self.now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
32
+
33
+ # Finishes the connections whose request's deadline passed, which wakes a
34
+ # read blocked on them with an IOError. One thread for every pool, started
35
+ # when first needed, and again in a forked process.
36
+ class Watchdog
37
+ Entry = Struct.new(:conn, :deadline, :fired)
38
+
39
+ def initialize
40
+ @fork_lock = Mutex.new
41
+ reset
42
+ end
43
+
44
+ # Returns the block's value, with +conn+ finished if +deadline+ passes
45
+ # first. An error raised once it was finished - an IOError from the read
46
+ # it woke, or whatever Net::HTTP raises on a connection finished under
47
+ # it - is the deadline's.
48
+ def watch(conn, deadline)
49
+ reset_after_fork
50
+ entry = Entry.new(conn, deadline, false)
51
+ @lock.synchronize do
52
+ @thread = Thread.new { run } unless @thread&.alive?
53
+ @entries << entry
54
+ @wake.signal
55
+ end
56
+ begin
57
+ yield
58
+ rescue StandardError
59
+ raise Net::ReadTimeout, "the call's deadline passed" if fired?(entry)
60
+
61
+ raise
62
+ ensure
63
+ # Under the lock: once removed, the connection is never finished
64
+ # again, so that it goes back to the pool safely.
65
+ @lock.synchronize { @entries.delete(entry) }
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # A forked process starts with fresh state: the parent's thread did not
72
+ # survive the fork, its entries are the parent's connections, and its
73
+ # lock may have been held by a thread which no longer exists - taking it
74
+ # would block forever.
75
+ def reset_after_fork
76
+ return if @pid == ::Process.pid
77
+
78
+ @fork_lock.synchronize do
79
+ reset unless @pid == ::Process.pid
80
+ end
81
+ end
82
+
83
+ def reset
84
+ @lock = Mutex.new
85
+ @wake = ConditionVariable.new
86
+ @entries = []
87
+ @thread = nil
88
+ @pid = ::Process.pid
89
+ end
90
+
91
+ def fired?(entry) = @lock.synchronize { entry.fired }
92
+
93
+ def run
94
+ Thread.current.name = "rubycent-watchdog"
95
+ @lock.synchronize do
96
+ loop do
97
+ now = HTTP.now
98
+ next_deadline = nil
99
+ @entries.each do |e|
100
+ next if e.fired
101
+
102
+ if e.deadline <= now
103
+ e.fired = true
104
+ finish(e.conn)
105
+ elsif next_deadline.nil? || e.deadline < next_deadline
106
+ next_deadline = e.deadline
107
+ end
108
+ end
109
+ @wake.wait(@lock, next_deadline && (next_deadline - now))
110
+ end
111
+ end
112
+ end
113
+
114
+ def finish(conn)
115
+ conn.finish
116
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
117
+ nil
118
+ end
119
+ end
120
+
121
+ WATCHDOG = Watchdog.new
122
+
123
+ # Connections kept alive per scheme, host and port. Safe for concurrent use.
124
+ class Pool
125
+ # @param options [Hash] Net::HTTP settings for every connection: ca_file,
126
+ # cert, key, verify_mode, local_host and the like
127
+ def initialize(options = {})
128
+ @options = options
129
+ @idle = Hash.new { |h, k| h[k] = [] }
130
+ @lock = Mutex.new
131
+ @closed = false
132
+ end
133
+
134
+ # @return [Hash] the Net::HTTP settings of its connections
135
+ attr_reader :options
136
+
137
+ # Sends a POST, and returns [status, headers, body]: the body of a
138
+ # status other than 200 read up to MAX_API_ERROR_BYTES.
139
+ def post(url, headers, body, deadline)
140
+ uri = URI.parse(url)
141
+ key = [uri.scheme, uri.host, uri.port]
142
+ conn = checkout(key, uri)
143
+ reusable = false
144
+ begin
145
+ out, reusable = WATCHDOG.watch(conn, deadline) { exchange(conn, uri, headers, body, deadline) }
146
+ reusable &&= conn.started?
147
+ out
148
+ ensure
149
+ reusable ? checkin(key, conn) : discard(conn)
150
+ end
151
+ end
152
+
153
+ def close
154
+ conns = @lock.synchronize do
155
+ @closed = true
156
+ all = @idle.values.flatten(1).map(&:first)
157
+ @idle.clear
158
+ all
159
+ end
160
+ conns.each { discard(it) }
161
+ end
162
+
163
+ private
164
+
165
+ # Sends the request on +conn+, and returns [[status, headers, body],
166
+ # whether the connection may be reused].
167
+ def exchange(conn, uri, headers, body, deadline)
168
+ left = time_left(deadline)
169
+ conn.open_timeout = left
170
+ conn.read_timeout = left
171
+ conn.write_timeout = left
172
+ conn.start unless conn.started?
173
+ req = Net::HTTP::Post.new(uri.request_uri, headers)
174
+ req.body = body
175
+ out = nil
176
+ reusable = false
177
+ catch(:drop) do
178
+ conn.request(req) do |res|
179
+ out = [res.code.to_i, res.to_hash.transform_values { it.join(", ") }, +""]
180
+ read(conn, res, out[2], deadline)
181
+ end
182
+ reusable = true
183
+ end
184
+ [out, reusable]
185
+ end
186
+
187
+ # Reads the body into +body+: whole for a 200, up to MAX_API_ERROR_BYTES
188
+ # otherwise, the rest drained so that the connection is reused - or
189
+ # dropped, by a throw, when there is too much. Each next chunk is read by
190
+ # the time left: a body arriving in pieces stops at the deadline.
191
+ def read(conn, res, body, deadline)
192
+ total = 0
193
+ res.read_body do |chunk|
194
+ if res.code == "200"
195
+ body << chunk
196
+ else
197
+ body << chunk.byteslice(0, MAX_API_ERROR_BYTES - body.bytesize) if body.bytesize < MAX_API_ERROR_BYTES
198
+ total += chunk.bytesize
199
+ throw :drop if total > MAX_DRAIN_BYTES
200
+ end
201
+ conn.read_timeout = time_left(deadline)
202
+ end
203
+ end
204
+
205
+ def time_left(deadline)
206
+ t = deadline - HTTP.now
207
+ raise Net::ReadTimeout, "the call's deadline passed" if t <= 0
208
+
209
+ t
210
+ end
211
+
212
+ def checkout(key, uri)
213
+ stale = []
214
+ conn = @lock.synchronize do
215
+ list = @idle[key]
216
+ while (entry = list.pop)
217
+ c, since = entry
218
+ break c if HTTP.now - since < IDLE_TIMEOUT
219
+
220
+ stale << c
221
+ end
222
+ end
223
+ stale.each { discard(it) }
224
+ return conn if conn
225
+
226
+ conn = Net::HTTP.new(uri.host, uri.port)
227
+ conn.use_ssl = uri.scheme == "https"
228
+ conn.keep_alive_timeout = IDLE_TIMEOUT
229
+ conn.max_retries = 0
230
+ @options.each { |k, v| conn.public_send(:"#{k}=", v) }
231
+ conn
232
+ end
233
+
234
+ def checkin(key, conn)
235
+ keep = @lock.synchronize do
236
+ next false if @closed || @idle[key].size >= MAX_IDLE
237
+
238
+ @idle[key] << [conn, HTTP.now]
239
+ true
240
+ end
241
+ discard(conn) unless keep
242
+ end
243
+
244
+ def discard(conn)
245
+ conn.finish if conn.started?
246
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
247
+ nil
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # What every result and message type has besides its fields: Hash and JSON
5
+ # forms, nested ones included, for rendering or logging a result -
6
+ # +render json: client.history(channel:)+ in Rails, say.
7
+ #
8
+ # res = client.history(channel: "news", limit: 1)
9
+ # res.to_h # => {publications: [{data: {"text" => "hi"}, info: nil, offset: 1, tags: {}}], offset: 1, ...}
10
+ # res.to_json # => '{"publications":[{"data":{"text":"hi"},...}],...}'
11
+ #
12
+ # Payloads stay as they are. An error in a result - a channel's, in a
13
+ # broadcast - is its message in JSON.
14
+ module Message
15
+ # The fields as a Hash with Symbol keys, nested messages as Hashes too.
16
+ #
17
+ # @return [Hash{Symbol => Object}]
18
+ def to_h(&)
19
+ return super if block_given?
20
+
21
+ super.transform_values { Message.deep(it) }
22
+ end
23
+
24
+ # The Hash a JSON encoder writes: {#to_h}, errors as their messages.
25
+ #
26
+ # @return [Hash{String => Object}]
27
+ def as_json(*) = Message.json(to_h)
28
+
29
+ # @return [String] the JSON of {#as_json}
30
+ def to_json(*) = as_json.to_json(*)
31
+
32
+ # @api private
33
+ def self.deep(v)
34
+ case v
35
+ when Message then v.to_h
36
+ when Array then v.map { deep(it) }
37
+ when Hash then v.transform_values { deep(it) }
38
+ else v
39
+ end
40
+ end
41
+
42
+ # @api private
43
+ def self.json(v)
44
+ case v
45
+ when Hash then v.to_h { |k, x| [k.to_s, json(x)] }
46
+ when Array then v.map { json(it) }
47
+ when Exception then v.message
48
+ else v
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/cent/pages.rb ADDED
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cent
4
+ # Where the publication went in the channel's stream.
5
+ class PublishResult
6
+ # @return [StreamPosition] the publication's offset and the stream's epoch:
7
+ # the since of a history call reading what came after it
8
+ def position = StreamPosition.new(offset:, epoch:)
9
+ end
10
+
11
+ # The channel's stream position when the history was read.
12
+ class HistoryResult
13
+ # @return [StreamPosition] the stream's top offset and epoch
14
+ def position = StreamPosition.new(offset:, epoch:)
15
+ end
16
+
17
+ # The map channel's stream position when its state was read.
18
+ class MapReadStateResult
19
+ # @return [StreamPosition] the stream's offset and epoch: as map_read_stream's
20
+ # since_offset and since_epoch, it reads the changes since
21
+ def position = StreamPosition.new(offset:, epoch:)
22
+ end
23
+
24
+ # The map channel's stream position when its stream was read.
25
+ class MapReadStreamResult
26
+ # @return [StreamPosition] the stream's offset and epoch
27
+ def position = StreamPosition.new(offset:, epoch:)
28
+ end
29
+
30
+ # The map channel's stream position after the publication.
31
+ class MapPublishResult
32
+ # @return [StreamPosition] the publication's offset in the map's stream and
33
+ # the stream's epoch
34
+ def position = StreamPosition.new(offset:, epoch:)
35
+ end
36
+
37
+ # The map channel's stream position after the removal.
38
+ class MapRemoveResult
39
+ # @return [StreamPosition] the removal's offset in the map's stream and the
40
+ # stream's epoch
41
+ def position = StreamPosition.new(offset:, epoch:)
42
+ end
43
+
44
+ # A publication read by history_all, with the epoch of the stream it was read
45
+ # from. The publication's fields read directly - +item.data+, +item.offset+ -
46
+ # and {#position} is where it is in the channel's stream: the since of a
47
+ # history call resuming after it, or a subscription's recover_since.
48
+ #
49
+ # @!attribute [r] publication
50
+ # @return [Publication]
51
+ # @!attribute [r] epoch
52
+ # @return [String] the epoch of the channel's stream
53
+ class HistoryItem < Data.define(:publication, :epoch)
54
+ include Message
55
+
56
+ Publication.members.each { |m| define_method(m) { publication.public_send(m) } }
57
+
58
+ # @return [StreamPosition] the publication's offset and the stream's epoch
59
+ def position = StreamPosition.new(offset: publication.offset, epoch:)
60
+ end
61
+
62
+ # An entry read by map_read_state_all, with the position of the state it was
63
+ # read at: the first page's, every later page being read at it, or the
64
+ # revision the request gave. The entry's fields read directly - +item.key+,
65
+ # +item.data+ - and map_read_stream from {#position} follows the state with
66
+ # every change made since.
67
+ #
68
+ # @!attribute [r] entry
69
+ # @return [MapEntry]
70
+ # @!attribute [r] state
71
+ # @return [StreamPosition] the position of the map's state the entry was
72
+ # read at
73
+ class MapStateItem < Data.define(:entry, :state)
74
+ include Message
75
+
76
+ MapEntry.members.each { |m| define_method(m) { entry.public_send(m) } }
77
+
78
+ # @return [StreamPosition] the state's position, {#state}
79
+ def position = state
80
+ end
81
+
82
+ # The <method>_all iterators of Client (spec 3.8): paging written once over
83
+ # the cursor, and once for history.
84
+ #
85
+ # @api private
86
+ module Pages
87
+ # Each paged method's result type, the field of its items, and the field
88
+ # holding the next page's cursor - empty when there is none.
89
+ PAGED = {
90
+ "history" => [HistoryResult, :publications, nil],
91
+ "device_list" => [DeviceListResult, :items, :next_cursor],
92
+ "device_topic_list" => [DeviceTopicListResult, :items, :next_cursor],
93
+ "user_topic_list" => [UserTopicListResult, :items, :next_cursor],
94
+ "map_read_state" => [MapReadStateResult, :entries, :cursor]
95
+ }.freeze
96
+
97
+ private
98
+
99
+ # The items of every page of +method+, the first page's request the JSON
100
+ # the block builds: an Enumerator, or each yielded to +block+. The request
101
+ # is checked here, before anything is sent.
102
+ def pages(method, page_timeout, block, &)
103
+ deadline(page_timeout, "page_timeout") # checked at the call, as the request is
104
+ first = encode_request(method, &)
105
+ check_history(first) if method == "history"
106
+ return Enumerator.new { |y| each_page(method, first, page_timeout) { y << it } } unless block
107
+
108
+ each_page(method, first, page_timeout, &block)
109
+ nil
110
+ end
111
+
112
+ def check_history(w)
113
+ limit = w["limit"] || 0
114
+ if limit < 1
115
+ raise InvalidRequestError, "invalid history request: history_all needs a limit of 1 or more, the page size " \
116
+ "(0 asks for the position alone, -1 for everything in one call)"
117
+ end
118
+ return unless w["reverse"]
119
+
120
+ raise InvalidRequestError, "invalid history request: history_all reads forward only: reverse is not supported"
121
+ end
122
+
123
+ def each_page(method, w, page_timeout)
124
+ result_class, items, cursor = PAGED.fetch(method)
125
+ # map_read_state's later pages are read at the first page's revision,
126
+ # unless the request pins one itself.
127
+ pin = method == "map_read_state" && !w.key?("revision_offset") && !w.key?("revision_epoch")
128
+ state = StreamPosition.new(offset: w["revision_offset"] || 0, epoch: w["revision_epoch"] || "") unless pin
129
+ loop do
130
+ res = invoke(method, result_class, page_timeout) { w }
131
+ page = res.public_send(items)
132
+ state ||= res.position if method == "map_read_state"
133
+ page.each do |item|
134
+ yield case method
135
+ when "history" then HistoryItem.new(publication: item, epoch: res.epoch)
136
+ when "map_read_state" then MapStateItem.new(entry: item, state:)
137
+ else item
138
+ end
139
+ end
140
+ if cursor.nil?
141
+ break if page.size < w["limit"]
142
+
143
+ w = w.merge("since" => position(page.last.offset, res.epoch))
144
+ else
145
+ following = res.public_send(cursor)
146
+ break if following.empty?
147
+
148
+ w = w.merge("cursor" => following)
149
+ if pin
150
+ w = w.merge(position(res.offset, res.epoch).transform_keys { "revision_#{it}" })
151
+ pin = false
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ # A stream position as the JSON of a request, zero values left out.
158
+ def position(offset, epoch)
159
+ w = {}
160
+ w["offset"] = offset unless offset.zero?
161
+ w["epoch"] = epoch unless epoch.empty?
162
+ w
163
+ end
164
+ end
165
+ end