diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb index 29f873f..8bd43f5 100644 --- a/gems/dexpace-core/lib/dexpace.rb +++ b/gems/dexpace-core/lib/dexpace.rb @@ -284,6 +284,29 @@ require_relative "dexpace/sse/stream" require_relative "dexpace/sse/typed_stream" +# Phase 7c: the pagination layer, in dependency order -- after 6b's block, though it needs only +# phase 2's closeable.rb, phase 1's model and the async pivot. The page value first, because it is +# also the namespace every later file reopens; then the strategy vocabulary (the info value, the +# query splice, the private link-header grammar, the three built-in strategies), the private +# lifetime owner and the private close disciplines both views share, the two views, the two +# engines, and the fetcher front-end last. `uri` is required by url.rb, which page.rb requires; it +# was on the allowlist before phase 1, and nothing under page/ names a serializer. +require_relative "dexpace/page" +require_relative "dexpace/page/page_state_error" +require_relative "dexpace/page/info" +require_relative "dexpace/page/query_rewriter" +require_relative "dexpace/page/link_header" +require_relative "dexpace/page/cursor_strategy" +require_relative "dexpace/page/page_number_strategy" +require_relative "dexpace/page/link_strategy" +require_relative "dexpace/page/walk" +require_relative "dexpace/page/closing" +require_relative "dexpace/page/items" +require_relative "dexpace/page/pages" +require_relative "dexpace/page/paginator" +require_relative "dexpace/page/async_paginator" +require_relative "dexpace/page/fetchers" + # The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client. # # This file issues explicit `require_relative`s for the whole tree rather than using an diff --git a/gems/dexpace-core/lib/dexpace/http/url.rb b/gems/dexpace-core/lib/dexpace/http/url.rb index 0347161..e0ceb91 100644 --- a/gems/dexpace-core/lib/dexpace/http/url.rb +++ b/gems/dexpace-core/lib/dexpace/http/url.rb @@ -54,6 +54,43 @@ def external_form(uri) uri.to_s end + # PAGE-19: RFC 3986 reference resolution of `reference` against `base`, or nil when the + # reference cannot resolve into a URI at all -- PAGE-19 treats that as end-of-stream and not + # as an error, and phase 7c's Dexpace::Page.next_request_from is the caller that turns the nil + # into one. The third function of this module, added by phase 7c (P7-3) beside .parse! so + # the parser pin lives in one file: .parse! cannot resolve, because it rejects the relative + # reference every `; rel=next` is (HTTP-47), and URI.join is what the + # Dexpace/NoUriDefaultParser cop refuses (url-and-query-encoding/08c54234). This is reference + # resolution and never SEAM-27's base-URL composition, which Dexpace::Operation does by hand + # because the two disagree on a query-bearing base (P2-3). + # + # Two things it deliberately does not do. It does not screen the result for a scheme this + # client can dispatch: `join` hands back a `mailto:`, a host-less `http:foo` and an empty-host + # `http:///p` as SUCCESSFUL resolutions (6b's P6-95), and whether such a target is an + # end-of-stream or an error is the caller's decision, made once in Dexpace::Page. And it does + # not accept a nil reference as "nothing to resolve": `join(base, nil)` raises ArgumentError, + # not URI::InvalidURIError, so a nil here is a programming error named as such rather than a + # silent end-of-stream. + # + # @param base [URI::Generic, String] the originating page's absolute URL + # @param reference [String] the raw target, absolute or relative + # @return [URI::Generic, nil] the resolved URI, its components frozen; nil when malformed + # @raise [Dexpace::InvalidArgumentError] when either argument is missing or of the wrong type + def resolve(base, reference) + Model.required!("base", base) + unless base.is_a?(String) || base.is_a?(::URI::Generic) + raise InvalidArgumentError, "base must be a String or a URI, got #{base.class}" + end + unless Model.required!("reference", reference).is_a?(String) + raise InvalidArgumentError, "reference must be a String, got #{reference.class}" + end + + parser = ::URI::RFC3986_PARSER #: untyped + own(parser.join(base.to_s, reference)) #: URI::Generic + rescue ::URI::InvalidURIError + nil + end + private # Freezes the URI's String components, then the URI. Only Strings: a URI also references diff --git a/gems/dexpace-core/lib/dexpace/page.rb b/gems/dexpace-core/lib/dexpace/page.rb new file mode 100644 index 0000000..8a9a89c --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "closeable" +require_relative "model" +require_relative "error/invalid_argument_error" +require_relative "http/request" +require_relative "http/response" +require_relative "http/url" + +module Dexpace + # One page of a paginated walk (PAGE-2, PAGE-3), and the namespace of the whole pagination + # subsystem -- Info, the three built-in strategies, the two views, the two engines and the + # fetcher front-end all nest here (P7-2). Design §7.1 names exactly one identifier for the + # subsystem, `Dexpace::Page`, and appendix A calls the value "a Page"; nesting the rest inside + # it keeps `lib/dexpace/page/**` as one audited directory. None of the nested names shadows a + # Ruby core constant or a `Dexpace::` one (the smoke suite pins the list). + # + # A page owns exactly one live Dexpace::Response, and whoever holds the page owns closing it: + # the component that fetched the response hands ownership over the moment it builds the page + # (PAGE-3). The materialized items and the derived status, headers and originating request + # stay readable after #close, because they are read off a frozen Data that closing does not + # touch; only the body is invalidated (PAGE-2). + # + # A plain class including Dexpace::Closeable, deliberately NOT a Data. A Data instance is + # frozen and cannot hold the latch (phase 3b's finding, applied one layer up), and the latch + # cannot be borrowed from the response either: Response#close is a pure forward to the body, + # and the body slot may hold nil, a BufferBody whose #close is a documented no-op, or -- only + # in the transport case -- a latching ResponseBody. So PAGE-27's exactly-once close, on every + # path that consumes a page, is a property of the page itself: the latch is here, #release + # forwards to the response once, and a second #close returns without touching it. + class Page + include Dexpace::Closeable + + private_class_method :new + + # The validating factory every page goes through: a real Dexpace::Response (checked the way + # Response#initialize checks its request), an Array of items, copied shallowly and frozen + # -- the collection is the model's, its elements stay the caller's (HTTP-5, XCUT-15; a deep + # copy through Model.own would replace and freeze domain objects and raise on a Proc, P7-101) + # -- and the two PAGE-34 keys, each nil or a String copied frozen, exactly as Info holds them: + # a key of another class is named and refused here rather than discovered by the fetcher + # front-end as a NoMethodError when it keys the next page off it (P7-107). + # + # @param response [Dexpace::Response] the live response this page owns from now on + # @param items [Array] the materialized items, possibly empty, never nil (PAGE-2) + # @param next_link [String, nil] PAGE-34's next link, when the source carried one + # @param continuation_token [String, nil] PAGE-34's fallback key + # @return [Dexpace::Page] + # @raise [Dexpace::InvalidArgumentError] on a missing response, a non-Array items, or a link + # or token that is neither nil nor a String + def self.build(response:, items:, next_link: nil, continuation_token: nil) + unless Model.required!("response", response).is_a?(Dexpace::Response) + raise InvalidArgumentError, "response must be a Dexpace::Response, got #{response.class}" + end + unless Model.required!("items", items).is_a?(::Array) + raise InvalidArgumentError, "items must be an Array, got #{items.class}" + end + + new(response, items, next_link, continuation_token) + end + + # PAGE-19's whole answer, shipped once: the next request for a rel=next target -- or any + # URL a strategy found in a header, a body or elsewhere -- resolved against the originating + # page's response URL, or nil for end-of-stream. Public rather than private to LinkStrategy + # because a next-page URL in the response BODY is the one shape a generated client commonly + # needs that none of the three built-ins covers, and a caller-written #parse that re-derives + # this branch by hand gets the two end-of-stream rules quietly wrong (P7-2). + # + # Four routes to nil, in order. A nil target, or a same-document reference -- RFC 3986 §4.4's + # two named forms, the empty reference and the fragment-only one, `<>` and `<#…>`, surrounding + # whitespace aside -- is end-of-stream BEFORE resolution is attempted: `URI::RFC3986_PARSER + # .join(base, "")` succeeds and answers the base itself, `join(base, "#x")` the base plus a + # fragment the wire never carries, so either `rel=next` would otherwise re-fetch the current + # page until the page cap (P7-5, widened to the fragment form by P7-117). A target that cannot + # resolve at all is end-of-stream, never an error (PAGE-19's own text). A target that resolves + # to something this client cannot dispatch -- no http or https scheme, or no host: `mailto:`, + # `javascript:`, `http:foo`, `http:///p`, every one a SUCCESSFUL join -- is end-of-stream too, + # consistent with REDIR-18's screen in the redirect step (P7-104). Otherwise + # `template.with(url:)`, which is PAGE-23's "swap only the URL, preserving the template's + # method, headers and body": Model#with routes through Request.build, so the other three + # members travel unchanged and HTTP-7 is re-validated. What is deliberately NOT screened is + # the URL a target resolves to: ``, which empties the query, ``, which uri resolves to + # the base itself, or the current URL spelled out are next requests like any other, and a + # server that loops through one is bounded by PAGE-9's cap -- a screen on the resolved URL + # would silently end a walk against an endpoint that advances server-side state under one + # URL, with no knob to turn it off. + # + # @param template [Dexpace::Request] the original request template + # @param response [Dexpace::Response] the page whose URL is the resolution base + # @param target [String, nil] the raw target, absolute or relative + # @return [Dexpace::Request, nil] nil is the end-of-stream signal (PAGE-4) + def self.next_request_from(template, response, target) + return nil if target.nil? || same_document?(target) + + resolved = Dexpace::URL.resolve(response.request.url, target) + return nil if resolved.nil? || !dispatchable?(resolved) + + template.with(url: resolved) + end + + # The schemes this client dispatches; the same two the redirect step screens for. + DISPATCHABLE_SCHEMES = %w[http https].freeze + private_constant :DISPATCHABLE_SCHEMES + + # RFC 3986 §4.4's two named same-document forms, read off the raw target before any + # resolution: empty (P7-5) or fragment-only (P7-117), surrounding whitespace ignored. Syntactic + # on purpose -- a browser reloads on a spelled-out link to the current page and only an empty + # or fragment-only one navigates nowhere -- so this reads the reference, never its resolution. + def self.same_document?(target) + stripped = target.strip + stripped.empty? || stripped.start_with?("#") + end + private_class_method :same_document? + + # 6b's screen, copied rather than called: Redirect::Location is a private_constant. + def self.dispatchable?(target) + scheme = target.scheme + host = target.host + !scheme.nil? && DISPATCHABLE_SCHEMES.include?(scheme.downcase) && !host.nil? && !host.empty? + end + private_class_method :dispatchable? + + def initialize(response, items, next_link, continuation_token) + @response = response + @items = items.dup.freeze + @next_link = optional_string("next_link", next_link) + @continuation_token = optional_string("continuation_token", continuation_token) + initialize_closeable(owned: true) + end + + # @return [Dexpace::Response] the live response; its body is invalid once the page is closed + attr_reader :response + # @return [Array] the frozen materialized items, readable after close (PAGE-2) + attr_reader :items + # @return [String, nil] PAGE-34's next link, as the source supplied it + attr_reader :next_link + # @return [String, nil] PAGE-34's continuation token, the fallback key + attr_reader :continuation_token + + # @return [Dexpace::Status] the response's status, readable after close (PAGE-2) + def status = @response.status + + # @return [Dexpace::Headers] the response's headers, readable after close (PAGE-2) + def headers = @response.headers + + # @return [Dexpace::Request] the originating (executed) request, readable after close (PAGE-2) + def request = @response.request + + private + + # nil, or a frozen copy of the String (XCUT-15); anything else is named and refused -- the same + # rule Info applies to the same two members, so a key travels from an Info to a Page unchanged. + def optional_string(name, value) + return nil if value.nil? + return Model.frozen_string(value) if value.is_a?(::String) + + raise InvalidArgumentError, "#{name} must be a String or nil, got #{value.class}" + end + + # Closeable's one obligation: forward to the response exactly once. A raising close leaves the + # latch flipped, so no second release is attempted and the failure propagates once (PAGE-3). + def release + @response.close + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/async_paginator.rb b/gems/dexpace-core/lib/dexpace/page/async_paginator.rb new file mode 100644 index 0000000..f71bb04 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/async_paginator.rb @@ -0,0 +1,372 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../model" +require_relative "../cancellation" +require_relative "../closeable" +require_relative "../async/completer" +require_relative "../async/future" +require_relative "../error/cancelled_error" +require_relative "../error/invalid_argument_error" +require_relative "../error/seam_error" +require_relative "../http/request" +require_relative "../http/request_options" +require_relative "closing" + +module Dexpace + class Page + # The non-blocking pagination engine (PAGE-25 through PAGE-33): the same frozen configuration + # as Paginator plus an optional executor, driving fetch, parse, delivery and re-arm inside the + # async completion graph through phase 2's Future#on_settle, with no thread blocking on a page. + # Two walk methods over one pump: #walk delivers items serially, #walk_pages delivers whole + # live pages -- PAGE-1's two views are required of the engine, not of the blocking one, and + # PAGE-27 names the page-level drain in as many words. + # + # R9, as built. This engine calls no wait of any kind -- not Clock#sleep, not Async.delay -- + # because no async PAGE requirement computes or requests a delay, so phase 5a's no-scheduler + # SeamError is unreachable from here. With no executor the driver is a callback pump: whoever + # settles the transport's future drives the next page, on that thread or fiber, which is + # PAGE-29's stated default; no thread is created, no pool held, no Fiber.scheduler consulted. + # With an executor -- a caller-supplied object answering `#post { ... }`, the one-method duck + # type, phase 8's dexpace-async-thread being the first real one -- the whole driver runs on it: + # the first dispatch and every settlement's continuation are posted, so every consumer + # invocation happens on the executor and a blocking consumer never ties up a transport + # callback thread (PAGE-29). A rejecting #post fails the walk with the rejection and closes the + # response it was carrying (PAGE-30); a rejecting first post fails the walk before any exchange. + # + # PAGE-31: the pump is a re-arm-flag trampoline in 6a's AsyncRetryStep image, never a callback + # that calls the next page: Future#on_settle runs its block INLINE on an already-settled future, + # so every scripted transport and every synchronously-answering adapter settles inline, and a + # pump that re-entered itself from the callback overflowed at ~2,600 pages through the real + # Completer on every interpreter. A settlement that arrives while the loop is running flips a + # flag under the pump's mutex -- held across the flip only, never across a dispatch, a parse, a + # consumer call or a close -- and the running loop picks the next page up; one that arrives + # later, on another thread, starts the loop afresh on a fresh stack. Every callback body is + # total: a StandardError fails the walk's future, a fatal-family error fails it and propagates, + # because a raising on_settle block would otherwise escape onto the settling thread through + # Hooks.notify's re-raise. + # + # The returned future settles with the number of pages delivered -- an Integer, never nil, + # because a Settlement carries exactly one of response or error and a nil success cannot be + # constructed (SEAM-16; P7-109) -- or fails with the ORIGINAL cause of a consumer throw, a + # transport failure, a parse failure or an eager transport raise, unwrapped (PAGE-28); a + # cancellation from either side settles it as a cancellation. + class AsyncPaginator < ::Data.define(:transport, :template, :strategy, :cap, :options, + :executor,) + include Model + + private_class_method :new + + # @param transport [#call] `#call(request, options, cancellation) -> Dexpace::Async::Future` + # @param template [Dexpace::Request] the first page's request + # @param strategy [_Strategy] a stateless parser answering `#parse(response, template)` + # @param cap [Numeric] PAGE-9's maximum number of exchanges, strictly positive + # @param options [Dexpace::RequestOptions] PAGE-36's per-call overrides, applied to every page + # @param executor [_Executor, nil] PAGE-29's executor mode; nil is the inline default + # @return [Dexpace::Page::AsyncPaginator] frozen + # @raise [Dexpace::InvalidArgumentError] when a member is missing or of the wrong shape + def self.build(transport:, template:, strategy:, cap: ::Float::INFINITY, + options: Dexpace::RequestOptions::EMPTY, executor: nil) + new(transport: transport, template: template, strategy: strategy, cap: cap, + options: options, executor: executor,) + end + + def initialize(transport:, template:, strategy:, cap:, options:, executor:) + validate_engine!(transport, template, strategy) + unless cap.is_a?(::Numeric) && cap.positive? + raise InvalidArgumentError, + "cap must be a strictly positive number, got #{cap.inspect} (PAGE-9)" + end + unless Model.required!("options", options).is_a?(Dexpace::RequestOptions) + raise InvalidArgumentError, + "options must be a Dexpace::RequestOptions, got #{options.class}" + end + unless executor.nil? || executor.respond_to?(:post) + raise InvalidArgumentError, + "executor must be nil or respond to #post, got #{executor.class}" + end + + super + end + + # The item-level walk: fetches immediately (PAGE-6's carve-out for the non-blocking engine) + # and delivers every item, one at a time and in server order, to `consumer.call(item)`, never + # concurrently (PAGE-29). Each page is closed after its items are drained, whether the + # consumer returned or raised; a throwing close on the success path fails the future, and one + # after a consumer failure is swallowed so the consumer's cause stays primary (PAGE-32). + # + # Cancelling the returned future halts the walk at the next page boundary and cancels the + # in-flight transport future (PAGE-25, PAGE-26): items already being delivered from the + # settling page still reach the consumer; a page fetched and parsed but not yet drained is + # closed quietly and dropped. **The inherent cancellation race, documented as PAGE-33 + # requires:** if the cancel settles the transport's future BEFORE the transport delivers its + # response, that response never reaches this engine's close path -- releasing it is the + # transport's responsibility, and for a future built on phase 2's Completer the completer does + # it (Completer#fulfil on a settled completer closes what it was handed, SEAM-30); conversely + # a page request already dispatched MAY still complete after the abort, and when it completes + # successfully this engine closes and discards the response at the page boundary rather than + # delivering it. A caller's `cancellation:` token is bridged to the same abort and released + # when the walk settles, so a client-lifetime token retains nothing per walk. + # + # @param consumer [#call] `#call(item)`, invoked serially + # @param cancellation [Dexpace::Cancellation, nil] a caller's token, passed to the transport + # @return [Dexpace::Async::Future] settles with the number of pages delivered + # @raise [Dexpace::InvalidArgumentError] when the consumer is not callable or the token is + # not a Dexpace::Cancellation + def walk(consumer, cancellation: nil) + start(consumer, cancellation, :items) + end + + # The page-level walk (PAGE-1's async half): the same pump, delivering each live Dexpace::Page + # once to `consumer.call(page)` BEFORE it is closed, so the consumer sees the open response + # for the length of its own call; every other rule -- PAGE-25's abort, PAGE-26's boundary, + # PAGE-27's exactly-once close, PAGE-30's staged-page close, PAGE-32's drain close -- is the + # one #walk states. PAGE-14's single-use latch has no counterpart here: each call is its own + # walk, which is PAGE-8. + # + # @param consumer [#call] `#call(page)`, invoked serially + # @param cancellation [Dexpace::Cancellation, nil] a caller's token, passed to the transport + # @return [Dexpace::Async::Future] settles with the number of pages delivered + # @raise [Dexpace::InvalidArgumentError] as #walk + def walk_pages(consumer, cancellation: nil) + start(consumer, cancellation, :pages) + end + + private + + def validate_engine!(transport, template, strategy) + unless Model.required!("transport", transport).respond_to?(:call) + raise InvalidArgumentError, "transport must respond to #call, got #{transport.class}" + end + unless Model.required!("template", template).is_a?(Dexpace::Request) + raise InvalidArgumentError, "template must be a Dexpace::Request, got #{template.class}" + end + return if Model.required!("strategy", strategy).respond_to?(:parse) + + raise InvalidArgumentError, "strategy must respond to #parse, got #{strategy.class}" + end + + def start(consumer, cancellation, mode) + unless Model.required!("consumer", consumer).respond_to?(:call) + raise InvalidArgumentError, "consumer must respond to #call, got #{consumer.class}" + end + unless cancellation.nil? || cancellation.is_a?(Dexpace::Cancellation) + raise InvalidArgumentError, + "cancellation: takes a Dexpace::Cancellation, got #{cancellation.class}" + end + + Pump.new(self, consumer, cancellation || Dexpace::Cancellation.none, mode).start + end + + # The per-walk state and the trampoline (PAGE-31), one instance per walk method call and + # reachable from nothing but the callbacks it registers: the completer, the next request, the + # exchange and page counts, the in-flight transport future, and the re-arm protocol's two + # flags under one mutex. A private_constant with a sig/ mirror. Over the class-length default + # and deliberately one class, as 6a's Pump is: the re-arm protocol, the fence, the drain and + # the terminal paths share the completer and the flags, and splitting them across objects + # would scatter the one invariant the trampoline rests on. + class Pump # rubocop:disable Metrics/ClassLength -- one per-walk state machine, see above + # @param paginator [Dexpace::Page::AsyncPaginator] + # @param consumer [#call] + # @param cancellation [Dexpace::Cancellation] the token the transport receives + # @param mode [Symbol] :items or :pages + def initialize(paginator, consumer, cancellation, mode) + @paginator = paginator + @consumer = consumer + @cancellation = cancellation + @mode = mode + @completer = Dexpace::Async::Completer.new + @next_request = paginator.template #: Dexpace::Request? + @exchanges = 0 + @pages = 0 + @in_flight = nil #: Dexpace::Async::Future? + @mutex = ::Thread::Mutex.new + @running = false + @rearm = false + end + + # Arms the two cancellation bridges, submits the first dispatch, and returns the future. + # + # @return [Dexpace::Async::Future] + def start + guarded do + @completer.on_cancel { |reason| @in_flight&.cancel(reason) } # PAGE-25 + unless @cancellation.equal?(Dexpace::Cancellation.none) + subscription = @cancellation.on_cancel { |reason| @completer.request_cancel(reason) } + @completer.future.on_settle { subscription.detach } + end + submit(nil) { resume } + end + @completer.future + end + + private + + # The re-arm protocol (PAGE-31): when a loop is already running on some frame, flip the + # flag and return -- that loop picks the next page up when its current one returns; + # otherwise become the loop and keep launching until nothing re-armed it. + def resume + return unless claim(:start) + + loop do + launch + break unless claim(:continue) + end + end + + # :start claims the loop when none runs (else re-arms and answers false); :continue + # consumes a re-arm (else releases the loop and answers false). The mutex is held across + # the flag flip only. + def claim(step) + @mutex.synchronize do + if step == :start && @running + @rearm = true + false + elsif step == :start + @running = true + elsif @rearm + @rearm = false + true + else + @running = false + end + end + end + + # One exchange: the same options every time (PAGE-36), the caller's token (or none), and the + # settlement callback, which hops to the executor when there is one. A transport that raises + # eagerly or answers something that is not a future is a failed walk (PAGE-28). + def launch + return if @completer.settled? + + guarded do + request = @next_request #: Dexpace::Request + future = @paginator.transport.call(request, @paginator.options, @cancellation) + unless future.respond_to?(:on_settle) + raise InvalidArgumentError, + "an async transport must answer a Dexpace::Async::Future, got #{future.class}" + end + + @exchanges += 1 + @in_flight = future + future.on_settle { |settlement| deliver(settlement) } + end + end + + # The settlement callback, total: inline by default, posted to the executor otherwise, so + # the driver -- parse, drain, close, re-arm -- runs where PAGE-29 puts it. + def deliver(settlement) + guarded { submit(settlement.response) { settled(settlement) } } + end + + # PAGE-29's one submit site, and PAGE-30's: a rejecting #post fails the walk with the + # rejection and closes the response it was carrying, quietly (the rejection is primary). + def submit(response, &) + executor = @paginator.executor + return yield if executor.nil? + + begin + executor.post(&) + rescue ::StandardError => error + Dexpace.close_quietly(response, onto: error) + @completer.fail(error) + end + nil + end + + # A downstream settlement, on whichever thread it lands: a response arriving after the walk + # settled is closed and discarded (PAGE-33's second half, PAGE-26's drop); a failure is + # forwarded as the same object and a cancellation as a cancellation (PAGE-28); otherwise + # parse, build the page, check the boundary again, drain, and advance or finish. + def settled(settlement) + response = settlement.response + guarded do + next Dexpace.close_quietly(response) if @completer.settled? + next forward(settlement) unless settlement.error.nil? + # PAGE-28's null-success clause has a code site although Settlement's own validation + # makes it unreachable: a null success completion terminates the walk exceptionally. + next null_success if response.nil? + + page = build_page(response) + # PAGE-26: staged, undrained, dropped + next Dexpace.close_quietly(page) if @completer.settled? + + drain(page) + @pages += 1 + advance + end + end + + def null_success + @completer.fail(Dexpace::SeamError.new("the transport future settled with nothing")) + end + + # A transport failure completes the walk with the ORIGINAL error object; a transport-side + # cancellation is forwarded as a cancellation carrying its reason (Future#then's rule). + def forward(settlement) + error = settlement.error #: Exception + if settlement.cancelled && error.is_a?(Dexpace::CancelledError) + @completer.request_cancel(error.reason) + else + @completer.fail(error) + end + end + + # PAGE-13 on this path: the response is closed inline on a parse failure and the parse error + # is primary. The next request is recorded before the page is built. + def build_page(response) + info = Closing.parse_or_close(@paginator.strategy, response, @paginator.template) + @next_request = info.next_request + Dexpace::Page.build(response: response, items: info.items, next_link: info.next_link, + continuation_token: info.continuation_token,) + end + + # PAGE-32's drain path: the page is released whether the consumer succeeds or throws. On + # the success path the close is bare and a throwing close propagates into the fence, which + # fails the future -- the walk terminates instead of hanging. When the consumer already + # failed the close is quiet, the consumer's cause stays primary and the close error is + # swallowed; PAGE-26's mid-drain rule holds because nothing here checks the completer + # between items. Item mode yields each item serially; page mode yields the live page once, + # before the close (PAGE-1, PAGE-27). + def drain(page) + if @mode == :items + page.items.each { |item| @consumer.call(item) } + else + @consumer.call(page) + end + rescue ::Exception # rubocop:disable Lint/RescueException -- PAGE-32: the consumer's cause stays primary + Dexpace.close_quietly(page) + raise + else + page.close + end + + # After a drained page: terminal when the strategy said end-of-stream (PAGE-4) or the cap is + # reached (PAGE-9); otherwise re-arm, which the running loop picks up or a fresh one starts. + def advance + if @next_request.nil? || @exchanges >= @paginator.cap + @completer.fulfil(@pages) + else + resume + end + end + + # The fence every callback body runs inside: a StandardError fails the future -- a raising + # on_settle block would otherwise escape onto the settling thread through Hooks.notify's + # re-raise and leave the future hanging -- and a fatal-family error fails it and then + # propagates unchanged (RECOV-2). Every site closes what it owns before it raises, so the + # fence closes nothing and cannot double-close. + def guarded + yield + rescue ::StandardError => error + @completer.fail(error) + rescue ::Exception => error # rubocop:disable Lint/RescueException -- fail, then re-raise + @completer.fail(error) + raise + end + end + private_constant :Pump + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/closing.rb b/gems/dexpace-core/lib/dexpace/page/closing.rb new file mode 100644 index 0000000..89cb8da --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/closing.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../suppressible" +require_relative "info" + +module Dexpace + class Page + # The two close disciplines every view, engine and walk in this subsystem shares, written once + # so PAGE-13's and PAGE-15's primary-error rules cannot drift between the four sites that need + # them. + # + # The trap both exist for was measured on every CI row (R8): a bare `ensure` whose close raises + # while a consumer exception is already in flight REPLACES the consumer's exception as the + # primary -- Ruby sets #cause to it, so nothing is lost, but PAGE-13's and PAGE-32's conformance + # clauses assert the exact opposite order. And the obvious repair, reading `$!` at the top of + # the `ensure`, is wrong in the other direction: `$!` is dynamically scoped to the thread and is + # non-nil inside anything called from a CALLER's `rescue` (pipeline/7ce4431d's measurement), so + # a walk driven from inside a consumer's rescue block would quietly attach its close error to an + # unrelated in-flight error instead of surfacing it. Neither `$!` nor a bare `ensure` appears in + # this subsystem (P7-105). The shape is 6b's: the frame that owns the walk records the primary + # in a `rescue ::Exception` arm that re-raises unchanged, and its `ensure` hands that local to + # .close_walk, which branches on it -- nil under a `break`, a normal return or a plain + # exhaustion, the consumer's error otherwise. + # + # Dexpace.close_quietly is deliberately NOT a third branch here: it is reached only where a + # requirement says SWALLOW -- PAGE-26's already-settled drop and PAGE-32's already-failed + # consumer, both in the async engine -- and using it anywhere else silently breaks PAGE-15's + # "surfaced, not swallowed". A private_constant with a sig/ mirror because the strict `core` + # Steep target types every file under lib/; asserted through the views and the lifetime suite. + module Closing + extend self + + # PAGE-15 and PAGE-13/PAGE-32's primary rule, at the walk's end: with nothing in flight a + # close failure is SURFACED; with `primary` in flight it is attached to the primary's + # suppressed trail through Dexpace.attach_suppressed, and the primary stays primary. The + # attach is a documented no-op on a frozen primary (phase 4b's P4-13), never worked around -- + # raising while attaching would mask the primary, which is the one thing this method exists + # to prevent. + # + # @param walk [Dexpace::Page::Walk] the walk to close; its #release closes both slots + # @param primary [Exception, nil] what the owning frame is already unwinding with, or nil + # @return [nil] + def close_walk(walk, primary) + walk.close + nil + rescue ::StandardError => error + raise if primary.nil? + + Dexpace.attach_suppressed(primary, error) + nil + end + + # PAGE-13: the strategy's parse, with the response closed INLINE on the exceptional path -- + # the page is never constructed, so nothing else would close it -- and the parse error kept + # primary: a close failure is attached to its trail, never raised over it. A fatal-family + # error (not a StandardError) closes the response quietly and propagates unchanged with no + # trail, RECOV-2's split (P7-110). The re-raise is spelled `raise error, cause: nil`, because + # the error was carried across a nested rescue and a bare `raise` would hand it whatever is in + # flight as a #cause (pipeline/7ce4431d). + # + # @param strategy [_Strategy] the paginator's strategy + # @param response [Dexpace::Response] the fetched response, owned by the caller until the + # Info comes back and by the Page it becomes afterwards + # @param template [Dexpace::Request] the original request template + # @return [Dexpace::Page::Info] + def parse_or_close(strategy, response, template) + info = strategy.parse(response, template) + unless info.is_a?(Info) + raise InvalidArgumentError, + "a strategy's parse must return a Dexpace::Page::Info, got #{info.class}" + end + + info + rescue ::StandardError => error + Dexpace.close_quietly(response, onto: error) + raise error, cause: nil + rescue ::Exception # rubocop:disable Lint/RescueException -- RECOV-2: the fatal family finds the response closed too, then propagates unchanged + Dexpace.close_quietly(response) + raise + end + end + private_constant :Closing + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb b/gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb new file mode 100644 index 0000000..5bc3652 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../model" +require_relative "info" +require_relative "query_rewriter" + +module Dexpace + class Page + # PAGE-16: the cursor strategy. Items and the next cursor come out of ONE call to a + # caller-supplied extractor, `#call(response) -> [items, cursor]`; a nil or empty cursor is + # end-of-stream; otherwise the next request is the template with the configurable cursor + # parameter (default `cursor`) spliced into its query through QueryRewriter -- replacing an + # earlier value in place, never appending a second (PAGE-23). + # + # An extractor, and never a codec or a witness (R7, P7-6): a keyword naming a serializer would + # put that word inside lib/dexpace/page/** and spec-forced boundary 5's audit would fail on the + # subsystem's own code. The consequence is stated rather than hidden: PAGE-16's "single read + # of the response body" is the EXTRACTOR's read, core performs none, and the suite asserts it + # against a read-counting body. An extractor that reads through Response#body_string gets the + # single-use rule enforced by phase 3b's body -- a second read raises Dexpace::ClosedError -- + # and one that reads `response.body.source` directly owes the same discipline (PAGE-5). The + # extractor is NOT rescued: a raise is a parse failure and PAGE-13 is its path, never an + # end-of-stream signal (PAGE-4) -- rescuing it would turn every server-side schema change into + # a silently truncated stream. + # + # A frozen Data with no instance state, so PAGE-5's "immutable and safe to share concurrently" + # is structural: one instance serves any number of walks at once. + class CursorStrategy < ::Data.define(:extract, :parameter) + include Model + + private_class_method :new + + # @param extract [#call] `#call(response) -> [items, cursor_or_nil]`; a caller who wants a + # codec writes it into this closure, outside core + # @param parameter [String] the cursor query parameter's name + # @return [Dexpace::Page::CursorStrategy] frozen + # @raise [Dexpace::InvalidArgumentError] when the extractor is not callable or the parameter + # is not a non-empty String + def self.build(extract:, parameter: "cursor") + new(extract: extract, parameter: parameter) + end + + def initialize(extract:, parameter:) + unless Model.required!("extract", extract).respond_to?(:call) + raise InvalidArgumentError, "extract must respond to #call, got #{extract.class}" + end + unless parameter.is_a?(::String) && !parameter.empty? + raise InvalidArgumentError, + "parameter must be a non-empty String, got #{parameter.inspect}" + end + + super(extract: extract, parameter: Model.frozen_string(parameter)) + end + + # One extractor call, then the derivation. The cursor also travels as the Info's + # continuation token, so a fetcher front-end over pages this strategy produced has PAGE-34's + # fallback key. + # + # @param response [Dexpace::Response] read once, through the extractor, never closed here + # @param template [Dexpace::Request] the original request the next one is derived from + # @return [Dexpace::Page::Info] + # @raise [Dexpace::InvalidArgumentError] when the extractor's answer is not `[Array, String?]` + def parse(response, template) + items, cursor = unpack(extract.call(response)) + return Info.terminal(items: items) if cursor.nil? || cursor.empty? + + url = QueryRewriter.rewrite_url(template.url, parameter, cursor) + Info.build(items: items, next_request: template.with(url: url), continuation_token: cursor) + end + + private + + # The extractor's contract, checked so a wrong shape is named at the strategy and not read as + # a silent end-of-stream by a destructuring that would put a bare Array in `items`. + def unpack(answer) + unless answer.is_a?(::Array) && answer.size == 2 + raise InvalidArgumentError, + "the cursor extractor must return [items, cursor], got #{answer.class}" + end + + items, cursor = answer + unless items.is_a?(::Array) + raise InvalidArgumentError, "the extractor's items must be an Array, got #{items.class}" + end + unless cursor.nil? || cursor.is_a?(::String) + raise InvalidArgumentError, + "the extractor's cursor must be a String or nil, got #{cursor.class}" + end + + [items, cursor] + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/fetchers.rb b/gems/dexpace-core/lib/dexpace/page/fetchers.rb new file mode 100644 index 0000000..1646f42 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/fetchers.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../model" +require_relative "../registry" +require_relative "../http/request_options" +require_relative "walk" +require_relative "items" +require_relative "pages" + +module Dexpace + class Page + # The fetcher-based front-end (PAGE-34, PAGE-35): the caller supplies a first-page fetcher and a + # next-page fetcher instead of a transport and a strategy, and gets the same two views over the + # same private Walk -- the item view, the page view with its look-ahead, and the two scoped + # openers. A frozen Data holding the two callables and the options; every per-walk quantity + # lives on the walk (PAGE-8). + # + # PAGE-34's rules, each a branch of the private Drive: the first-page fetcher is called exactly + # once per walk; every later page keys the next-page fetcher off the previous page's next link, + # falling back to its continuation token when the link is absent or blank -- next link wins; a + # blank link with no fallback token, or a nil page from either fetcher, ends the stream, and a + # nil FIRST page is an empty stream rather than an error. **Each fetcher builds a Dexpace::Page + # that owns its response and MUST NOT close it** -- ownership transfers to the page and the + # walk releases it (PAGE-3) -- **and a fetcher that raises before building the page remains + # responsible for that response**: core cannot reach a response a fetcher never handed it. + # + # PAGE-35, vacuous by construction on design §12's own authority: the SHOULD is conditional on + # a MUTABLE paging-options object, and this port offers a frozen Dexpace::RequestOptions + # instead, so the clause is vacuous rather than declined. What the port does supply: the SAME + # frozen instance is threaded to every fetcher that takes it, by identity; a fetcher that wants + # per-page state has the previous page's `continuation_token` as a first-class channel + # (PAGE-34), and a Ruby callable carries its own binding for anything else. A fetcher's arity + # decides whether it is called with the options: `-> { }` and `->(key) { }` are called without + # them, `->(options) { }` and `->(key, options) { }` with them. + class Fetchers < ::Data.define(:first, :next_page, :options) + include Model + + private_class_method :new + + # @param first [#call] `#call -> Dexpace::Page | nil`, or `#call(options)` + # @param next_page [#call] `#call(key) -> Dexpace::Page | nil`, or `#call(key, options)`; + # `key` is the previous page's next link, or its continuation token when the link is blank + # @param options [Dexpace::RequestOptions] handed, by identity, to a fetcher that takes it + # @return [Dexpace::Page::Fetchers] frozen + # @raise [Dexpace::InvalidArgumentError] when a fetcher is not callable or the options are not + # a Dexpace::RequestOptions + def self.build(first:, next_page:, options: Dexpace::RequestOptions::EMPTY) + new(first: first, next_page: next_page, options: options) + end + + def initialize(first:, next_page:, options:) + unless Model.required!("first", first).respond_to?(:call) + raise InvalidArgumentError, "first must respond to #call, got #{first.class}" + end + unless Model.required!("next_page", next_page).respond_to?(:call) + raise InvalidArgumentError, "next_page must respond to #call, got #{next_page.class}" + end + unless Model.required!("options", options).is_a?(Dexpace::RequestOptions) + raise InvalidArgumentError, + "options must be a Dexpace::RequestOptions, got #{options.class}" + end + + super + end + + # The item-level view over a fetcher-driven walk (PAGE-11's eager close, PAGE-8's fresh walk + # per iteration). + # + # @return [Dexpace::Page::Items] + def items + Items.new(method(:open_walk)) + end + + # The page-level view over a fetcher-driven walk (PAGE-12, PAGE-14). + # + # @return [Dexpace::Page::Pages] + def pages + Pages.new(method(:open_walk)) + end + + # The scoped opener for items; closes the walk on every exit. + # + # @yieldparam item [Object] + # @return [nil] + # @raise [Dexpace::InvalidArgumentError] without a block + def each_item(&block) + raise InvalidArgumentError, "each_item requires a block" if block.nil? + + items.each(&block) + nil + end + + # The scoped opener for pages; closes both held pages on every exit. + # + # @yieldparam page [Dexpace::Page] + # @return [nil] + # @raise [Dexpace::InvalidArgumentError] without a block + def each_page(&block) + raise InvalidArgumentError, "each_page requires a block" if block.nil? + + pages.each(&block) + nil + end + + # The fetcher front-end's per-walk exchange routine (P7-111): whether the first fetcher has + # run, and the previous page's key. Each call answers a Dexpace::Page or nil, exactly as the + # strategy engine's drive does, so the Walk is shared unchanged. + class Drive + # @param fetchers [Dexpace::Page::Fetchers] + def initialize(fetchers) + @fetchers = fetchers + @started = false + @key = nil #: String? + end + + # @return [Dexpace::Page, nil] + def call + page = @started ? fetch_next : fetch_first + @started = true + return nil if page.nil? + unless page.is_a?(Dexpace::Page) + raise InvalidArgumentError, + "a fetcher must return a Dexpace::Page or nil, got #{page.class}" + end + + @key = key_of(page) + page + end + + private + + def fetch_first + fetcher = @fetchers.first + takes_options = Dexpace::Registry.callable?(fetcher, arity: 1) + takes_options ? fetcher.call(@fetchers.options) : fetcher.call + end + + # nil when the previous page carried neither a link nor a token: end-of-stream (PAGE-34). + def fetch_next + key = @key + return nil if key.nil? + + fetcher = @fetchers.next_page + if Dexpace::Registry.callable?(fetcher, arity: 2) + fetcher.call(key, @fetchers.options) + else + fetcher.call(key) + end + end + + # PAGE-34: the next link wins; a blank one falls back to the continuation token; both blank + # is nil. + def key_of(page) + link = page.next_link + return link unless link.nil? || link.strip.empty? + + token = page.continuation_token + token.nil? || token.strip.empty? ? nil : token + end + end + private_constant :Drive + + private + + # A fresh Walk over a fresh Drive, uncapped: the fetchers are the caller's own code, and + # PAGE-9's cap bounds a server, not a callable the caller wrote. + def open_walk + Walk.new(Drive.new(self), cap: ::Float::INFINITY) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/info.rb b/gems/dexpace-core/lib/dexpace/page/info.rb new file mode 100644 index 0000000..22a552b --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/info.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../model" +require_relative "../http/request" + +module Dexpace + class Page + # PAGE-4's PageInfo: what a strategy's #parse returns -- the page's items plus the fully-formed + # request for the next page, or nil for end-of-stream. + # + # `next_request == nil` is the single, exclusive end-of-stream signal, and there is no second + # one: no `terminal?` flag, no sentinel and no exception path, which is PAGE-4's prohibition + # made structural. .terminal is the named factory for that case so a strategy author never + # writes `next_request: nil` by hand and never mistakes an empty items list for termination: + # an empty list paired with a non-nil next request is a valid NON-terminal page, and the + # factory names the other one. + # + # The items Array is copied shallowly and frozen -- the collection is the model's, its + # elements stay the caller's domain objects (P7-101); Model.own's deep copy would replace + # them, freeze them and raise on a Proc. The two optional keys are PAGE-34's: a strategy that + # found a next link or a continuation token records it here so the Page it becomes carries + # them for the fetcher front-end. + class Info < ::Data.define(:items, :next_request, :next_link, :continuation_token) + include Model + + private_class_method :new + + # The validating factory every construction path goes through. + # + # @param items [Array] the page's items, possibly empty, never nil + # @param next_request [Dexpace::Request, nil] nil is the end-of-stream signal (PAGE-4) + # @param next_link [String, nil] PAGE-34's next link, when the source carried one + # @param continuation_token [String, nil] PAGE-34's fallback key + # @return [Dexpace::Page::Info] + # @raise [Dexpace::InvalidArgumentError] on a nil or non-Array items, a next_request that is + # not a Dexpace::Request, or a non-String link or token + def self.build(items:, next_request:, next_link: nil, continuation_token: nil) + new(items: items, next_request: next_request, next_link: next_link, + continuation_token: continuation_token,) + end + + # The end-of-stream page: these items and no next request. + # + # @param items [Array] the last page's items, empty by default + # @return [Dexpace::Page::Info] + def self.terminal(items: []) + build(items: items, next_request: nil) + end + + def initialize(items:, next_request:, next_link:, continuation_token:) + unless Model.required!("items", items).is_a?(::Array) + raise InvalidArgumentError, "items must be an Array, got #{items.class}" + end + unless next_request.nil? || next_request.is_a?(Dexpace::Request) + raise InvalidArgumentError, + "next_request must be a Dexpace::Request or nil, got #{next_request.class}" + end + + super(items: items.dup.freeze, next_request: next_request, + next_link: optional_string("next_link", next_link), + continuation_token: optional_string("continuation_token", continuation_token),) + end + + private + + # nil, or a frozen copy of the String (XCUT-15); anything else is named and refused. + def optional_string(name, value) + return nil if value.nil? + return Model.frozen_string(value) if value.is_a?(::String) + + raise InvalidArgumentError, "#{name} must be a String or nil, got #{value.class}" + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/items.rb b/gems/dexpace-core/lib/dexpace/page/items.rb new file mode 100644 index 0000000..bf7c6ad --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/items.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../error/invalid_argument_error" +require_relative "closing" +require_relative "walk" + +module Dexpace + class Page + # The item-level view (PAGE-1): every page's items flattened into one sequence in server order + # across page boundaries, over a walk the view opens afresh on EVERY #each -- PAGE-8's "each + # independent iteration MUST restart from the initial request with its own fresh state" -- + # with each page closed BEFORE any of its items is yielded (PAGE-11), after its items were + # copied into the page's frozen list. + # + # The eager close is what makes this view safe on a host where an abandoned Enumerator's + # ensure never runs: the only suspension points an external consumer can abandon the walk at + # are points where no response is live, so the residue of pagination/b2a85752 on this view is + # zero. It is also why #close is a documented no-op rather than an omission -- the walk is a + # local of #each so that overlapping iterations stay independent, so there is no walk for the + # view to hold, and by PAGE-11 there is nothing open to release at any point a caller could + # call it. A `@walk` stored on the view to give #close something to do would break PAGE-8 the + # moment two iterations overlap. + # + # `include ::Enumerable` gives `first`, `take`, `lazy`, `count` and the rest for free + # (design §7.1), each of them driving #each; `first` and `take` stop early and spend exactly + # the exchanges they consume (PAGE-6). Constructed by the engine through Paginator#items or + # Fetchers#items, never directly. + class Items + include ::Enumerable + + # @param opener [#call] the engine's walk factory, `#call -> Walk`, one fresh walk per call + # @raise [Dexpace::InvalidArgumentError] when the opener is not callable + def initialize(opener) + unless opener.respond_to?(:call) + raise InvalidArgumentError, "an item view is opened by the engine, got #{opener.class}" + end + + @opener = opener + end + + # Yields every item in server order, fetching one page per page consumed (PAGE-6) and closing + # each page before its first item is yielded (PAGE-11). Without a block, an Enumerator over + # the same routine -- the one permitted block_given? in this subsystem, the + # return-an-enumerator idiom and never a guard. An Enumerator abandoned mid-#next runs no + # ensure, and on this view that costs nothing: PAGE-11 has already closed every page it + # pulled. + # + # @yieldparam item [Object] + # @return [self, Enumerator] + def each(&) + return to_enum(:each) unless block_given? + + walk = @opener.call # PAGE-8: fresh state per iteration + primary = nil #: Exception? + begin + while (page = walk.fetch_next_page) + items = page.items # PAGE-11: already a frozen copy + page.close # PAGE-11: close BEFORE any item is yielded; a failure surfaces (PAGE-15) + items.each(&) + end + rescue ::Exception => error # rubocop:disable Lint/RescueException -- recorded for the ensure's Closing route, re-raised unchanged + primary = error + raise + ensure + Closing.close_walk(walk, primary) + end + self + end + + # A documented no-op, kept so the two views share one lifetime vocabulary: this view holds no + # walk (PAGE-8) and PAGE-11 leaves nothing open at any point it could be called. Pages#close + # is the one that releases. + # + # @return [nil] + def close + nil + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/link_header.rb b/gems/dexpace-core/lib/dexpace/page/link_header.rb new file mode 100644 index 0000000..b7675eb --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/link_header.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" + +module Dexpace + class Page + # PAGE-18 and PAGE-20: the RFC 8288 link-value grammar, as a character-level state machine, and + # the one question the Link strategy asks of it -- the first link-value whose `rel` carries the + # token `next`. + # + # Not a regexp, and that is spec-forced boundary 22 rather than a style preference: RFC 8288's + # grammar is not regular. A comma inside the angle-bracketed URI-reference and a comma inside a + # quoted parameter value must not split link-values, a semicolon inside a quoted value must not + # split parameters, and a quoted-pair escape (`\"`) must be honoured -- exactly the inputs + # PAGE-18's conformance clause names, and exactly where a pattern that appeared to work would + # fail. So the per-pattern-timeout constraint is discharged by not writing the pattern, the + # route design §6.3 takes for WWW-Authenticate; no Regexp is compiled in this file and the + # suite scans for one. The `rel` value is accepted quoted or unquoted, split on space and tab + # into relation types, and each token compared through the no-argument `downcase` + # (Dexpace/NoLocaleCaseFold) -- the one fold site in the subsystem. A parameter NAME is folded + # too, so `REL=next` is `rel=next` (RFC 8288 §3: parameter names are case-insensitive), and + # only the FIRST `rel` parameter of a link-value is read: RFC 8288 §3.3 says `rel` MUST NOT + # appear more than once and occurrences after the first MUST be ignored, so + # `; rel="prev"; rel="next"` is a prev link and never a next one (P7-116). + # + # It never raises. Malformed input -- a value with no angle brackets, a reference never closed, + # a quoted string never closed, an escape at the very end -- yields the link-values scanned so + # far and drops the broken parameter, which the strategy reads as end-of-stream (PAGE-18's + # "absence of a rel=next segment"); a blank target `<>` comes back as "" and is the strategy's + # P7-5. Several header instances are joined with ", " first, which is PAGE-20's normalisation + # by concatenation: the grammar is the same whether the server sent one instance or three. A + # private_constant, asserted at LinkStrategy's call site and through const_get in its own + # suite, with a sig/ mirror because the strict `core` Steep target types every file under lib/. + module LinkHeader + extend self + + OWS = [" ", "\t"].freeze + # What separates link-values from one another, with the whitespace around them. + SEPARATORS = [" ", "\t", ","].freeze + # What ends a parameter name or an unquoted parameter value. + TOKEN_END = [" ", "\t", ";", ",", "="].freeze + private_constant :OWS, :SEPARATORS, :TOKEN_END + + # @param values [Array, nil] the Link header's instances, as Headers#[] returns them + # @return [String, nil] the first rel=next URI-reference, raw, or nil for end-of-stream + def next_target(values) + return nil if values.nil? || values.empty? + + link_values(values.join(", ")).each do |target, params| + rel = params.find { |name, _value| name == "rel" } + return target if !rel.nil? && next_token?(rel[1]) + end + nil + end + + private + + # PAGE-18: `rel` lists space- or tab-separated relation types; the match is the whole token, + # case-insensitively. A bare `split` is awk-style whitespace splitting and compiles nothing. + def next_token?(value) + value.split.any? { |token| token.downcase == "next" } + end + + # Every `[target, params]` pair in order, stopping at the first malformed link-value: one + # that does not open with `<` or never closes its `>`. + def link_values(text) + results = [] #: Array[[String, Array[[String, String]]]] + index = skip(text, 0, SEPARATORS) + while index < text.length && text[index] == "<" + close = text.index(">", index + 1) + break if close.nil? + + target = text[(index + 1)...close] || "" + params, index = parse_params(text, close + 1) + results << [target, params] + index = skip(text, index, SEPARATORS) + end + results + end + + # The index of the first character at or after `index` that is not in `set`. + def skip(text, index, set) + index += 1 while index < text.length && set.include?(text[index]) + index + end + + # The `; name=value` list after a `>`, up to the next top-level comma or the end of the text. + # Anything other than `;` or `,` where a separator is expected ends this link-value's + # parameters at the next comma. + # + # @return [Array(Array, Integer)] the pairs, and where to resume + def parse_params(text, index) + params = [] #: Array[[String, String]] + loop do + index = skip(text, index, OWS) + return [params, index] if index >= text.length || text[index] == "," + return [params, text.index(",", index) || text.length] unless text[index] == ";" + + pair, index = parse_param(text, index + 1) + params << pair unless pair.nil? + end + end + + # One `name` or `name=value` after a `;`; nil for an empty name or an unterminated quoted + # value, either of which is dropped rather than raised on. + # + # @return [Array(Array(String, String), Integer)] the pair (or nil), and where to resume + def parse_param(text, index) + name, index = read_token(text, skip(text, index, OWS)) + index = skip(text, index, OWS) + value = "" #: String? + value, index = read_value(text, skip(text, index + 1, OWS)) if text[index] == "=" + return [nil, index] if name.empty? || value.nil? + + pair = [name.downcase, value] #: [String, String] + [pair, index] + end + + def read_value(text, index) + text[index] == '"' ? read_quoted(text, index + 1) : read_token(text, index) + end + + def read_token(text, index) + start = index + index += 1 while index < text.length && !TOKEN_END.include?(text[index]) + [text[start...index] || "", index] + end + + # From just after the opening quote to just after the closing one, unescaping quoted-pairs. + # An unterminated string, or an escape as the very last character, is nil and consumes the + # rest: there is no grammar left to honour. + def read_quoted(text, index) + value = +"" + while index < text.length + char = text[index] + return [value, index + 1] if char == '"' + + if char == "\\" + return [nil, text.length] if index + 1 >= text.length + + index += 1 + char = text[index] + end + value << char.to_s + index += 1 + end + [nil, index] + end + end + private_constant :LinkHeader + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/link_strategy.rb b/gems/dexpace-core/lib/dexpace/page/link_strategy.rb new file mode 100644 index 0000000..9e6c3fd --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/link_strategy.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../model" +require_relative "info" +require_relative "link_header" + +module Dexpace + class Page + # PAGE-18, PAGE-19, PAGE-20: the Link-header strategy. The items come out of a caller-supplied + # `#call(response) -> items` extractor; the next request comes from the response's Link + # header(s), read under the fold through Headers#[] -- every instance the server sent, which + # LinkHeader joins and scans by RFC 8288's grammar for the first link-value whose `rel` carries + # the token `next` -- and resolved through Dexpace::Page.next_request_from against the + # originating page's response URL, which is where PAGE-19's three end-of-stream routes live: a + # blank target (P7-5), an unresolvable one, and one this client cannot dispatch (P7-104). No + # header, no rel=next segment, or any of the three, is end-of-stream and never an error. The + # header name is configurable and defaults to `Link`. + # + # The raw target travels as the Info's `next_link`, so the fetcher front-end's PAGE-34 key is + # available on pages this strategy produced. The extractor is not rescued (PAGE-4). A frozen + # Data with no instance state (PAGE-5). + class LinkStrategy < ::Data.define(:extract_items, :header) + include Model + + private_class_method :new + + # @param extract_items [#call] `#call(response) -> items` + # @param header [String] the header to read the link-values from + # @return [Dexpace::Page::LinkStrategy] frozen + # @raise [Dexpace::InvalidArgumentError] when the extractor is not callable or the header + # name is not a non-empty String + def self.build(extract_items:, header: "Link") + new(extract_items: extract_items, header: header) + end + + def initialize(extract_items:, header:) + unless Model.required!("extract_items", extract_items).respond_to?(:call) + raise InvalidArgumentError, + "extract_items must respond to #call, got #{extract_items.class}" + end + unless header.is_a?(::String) && !header.empty? + raise InvalidArgumentError, "header must be a non-empty String, got #{header.inspect}" + end + + super(extract_items: extract_items, header: Model.frozen_string(header)) + end + + # @param response [Dexpace::Response] read once, through the extractor, never closed here + # @param template [Dexpace::Request] the original request the next one is derived from + # @return [Dexpace::Page::Info] + # @raise [Dexpace::InvalidArgumentError] when the extractor's answer is not an Array + def parse(response, template) + items = extract_items.call(response) + unless items.is_a?(::Array) + raise InvalidArgumentError, "the extractor's items must be an Array, got #{items.class}" + end + + target = LinkHeader.next_target(response.headers[header]) + next_request = Dexpace::Page.next_request_from(template, response, target) + return Info.terminal(items: items) if next_request.nil? + + Info.build(items: items, next_request: next_request, next_link: target) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/page_number_strategy.rb b/gems/dexpace-core/lib/dexpace/page/page_number_strategy.rb new file mode 100644 index 0000000..cc1d1c1 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/page_number_strategy.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../model" +require_relative "info" +require_relative "query_rewriter" + +module Dexpace + class Page + # PAGE-17: the page-number strategy. An empty items list is end-of-stream, checked FIRST and + # before anything else is computed -- defensive against a server that returns an empty page + # past the end. Otherwise the current page is read from the ORIGINATING (executed) request's + # page parameter, which in this port is `response.request` and not the template: after a + # redirect chain the executed request is the final hop's, and it is the one the server + # answered. An absent, empty or non-numeric value falls back to the configurable start page + # (default 1; 0 is permitted for 0-based servers), and the next request is the template with + # `current + 1` spliced into its page parameter (default `page`). + # + # The numeric screen is one anchored ASCII-digit pattern compiled once with its own + # `Regexp.new(source, timeout:)` -- per-pattern, never the process-global `Regexp.timeout` a + # library must not impose on its host (design §4). `[0-9]` rather than `\d`, so a non-ASCII + # digit is "non-numeric" and falls back, as the requirement's garbage case asks. The extractor + # is `#call(response) -> items` and is not rescued (PAGE-4; see CursorStrategy). A frozen Data + # with no instance state (PAGE-5). + class PageNumberStrategy < ::Data.define(:extract_items, :parameter, :start) + include Model + + private_class_method :new + + DIGITS = ::Regexp.new("\\A[0-9]+\\z", timeout: 1.0) + private_constant :DIGITS + + # @param extract_items [#call] `#call(response) -> items` + # @param parameter [String] the page-number query parameter's name + # @param start [Integer] the first page's number; 0 for a 0-based server + # @return [Dexpace::Page::PageNumberStrategy] frozen + # @raise [Dexpace::InvalidArgumentError] when the extractor is not callable, the parameter is + # not a non-empty String, or the start is not a non-negative Integer + def self.build(extract_items:, parameter: "page", start: 1) + new(extract_items: extract_items, parameter: parameter, start: start) + end + + def initialize(extract_items:, parameter:, start:) + unless Model.required!("extract_items", extract_items).respond_to?(:call) + raise InvalidArgumentError, + "extract_items must respond to #call, got #{extract_items.class}" + end + unless parameter.is_a?(::String) && !parameter.empty? + raise InvalidArgumentError, + "parameter must be a non-empty String, got #{parameter.inspect}" + end + unless start.is_a?(::Integer) && !start.negative? + raise InvalidArgumentError, "start must be a non-negative Integer, got #{start.inspect}" + end + + super(extract_items: extract_items, parameter: Model.frozen_string(parameter), start: start) + end + + # @param response [Dexpace::Response] read once, through the extractor, never closed here + # @param template [Dexpace::Request] the original request the next one is derived from + # @return [Dexpace::Page::Info] + # @raise [Dexpace::InvalidArgumentError] when the extractor's answer is not an Array + def parse(response, template) + items = extract_items.call(response) + unless items.is_a?(::Array) + raise InvalidArgumentError, "the extractor's items must be an Array, got #{items.class}" + end + return Info.terminal(items: items) if items.empty? + + url = QueryRewriter.rewrite_url(template.url, parameter, (current_page(response) + 1).to_s) + Info.build(items: items, next_request: template.with(url: url)) + end + + private + + # The executed request's page number, or `start` when the parameter is absent, empty or + # not a run of ASCII digits. + def current_page(response) + value = QueryRewriter.get(response.request.url.query, parameter) + value.nil? || !DIGITS.match?(value) ? start : value.to_i + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/page_state_error.rb b/gems/dexpace-core/lib/dexpace/page/page_state_error.rb new file mode 100644 index 0000000..577b35b --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/page_state_error.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" +require_relative "../page" + +module Dexpace + class Page + # PAGE-14's "re-iteration MUST fail rather than silently restart": the error a second #each + # on a single-use Pages view raises. A STATE error and never Dexpace::InvalidArgumentError -- + # no argument is involved, the object was simply used twice -- so a caller rescuing + # ArgumentError around a page loop does not catch it, and it sits in the same family as the + # SSE facade's state error for the identical single-use latch (phase 10's inbound bullet of + # 2026-09-13 on PAGE-14 and SSE-26; P7-103). A StandardError including Dexpace::Error, like + # every namespaced error since phase 6c; a shared core supertype for the two subsystems' state + # errors is a pure widening phase 10 can insert. + class PageStateError < ::StandardError + include Dexpace::Error + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/pages.rb b/gems/dexpace-core/lib/dexpace/page/pages.rb new file mode 100644 index 0000000..846ed5e --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/pages.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../error/invalid_argument_error" +require_relative "closing" +require_relative "page_state_error" +require_relative "walk" + +module Dexpace + class Page + # The page-level view (PAGE-1): whole Dexpace::Pages in order, each live -- status, headers, + # originating request and the open response -- for the length of the consumer's turn. One walk, + # allocated when the view is, and single-use: the iterator may be obtained at most once, and a + # second #each raises PageStateError rather than silently restarting (PAGE-14); two views from + # one engine are two walks and two full fetch sequences (PAGE-8). + # + # PAGE-12's auto-closing discipline, verbatim: the previous page is closed as the consumer + # advances, the last page at exhaustion, and a page fetched by a probe but not yet delivered is + # staged in storage the WALK owns -- its one-slot look-ahead, never the enumerator's closure + # (pagination/9bdf90fc) -- so an emptiness probe or an early break followed by #close still + # releases it; #close releases both the held page and the staged one. Up to two live pages can + # exist at once. **Wrap the view in Paginator#each_page or Fetchers#each_page, or call #close + # yourself**: an Enumerator abandoned mid-#next runs no ensure, so a page pulled through + # external iteration and then dropped stays open until #close, which is the residue design + # §7.1 states rather than hides. + # + # `include ::Enumerable` gives `first`, `take`, `lazy` and the rest, each of them one drive of + # #each -- so `view.first` is the whole single use. Constructed by the engine through + # Paginator#pages or Fetchers#pages, never directly. + class Pages + include ::Enumerable + + # @param opener [#call] the engine's walk factory, `#call -> Walk` + # @raise [Dexpace::InvalidArgumentError] when the opener is not callable + def initialize(opener) + unless opener.respond_to?(:call) + raise InvalidArgumentError, "a page view is opened by the engine, got #{opener.class}" + end + + @walk = opener.call + @viewed = false + end + + # Yields each live page in order, closing the previous one as it advances and the last at + # exhaustion, and both held pages on every exit -- a return, a `break`, a raise (PAGE-12). + # Without a block, the Enumerator over the same routine; obtaining it IS the one use, so a + # later block call raises. A second call in either form raises PageStateError (PAGE-14). + # + # @yieldparam page [Dexpace::Page] live for the length of the block, closed after it + # @return [self, Enumerator] + # @raise [Dexpace::Page::PageStateError] on a second call + def each(&) + claim! + return to_enum(:drive) unless block_given? + + drive(&) + self + end + + # PAGE-12's emptiness probe. **The first call runs an HTTP exchange**: probing for the next + # page eagerly fetches it, and the page is staged in the walk's look-ahead slot until the + # consumer advances to it or the view is closed. A repeated probe reads the staged page and + # costs nothing (PAGE-6); a closed or exhausted walk fetches nothing and answers false. + # + # @return [Boolean] whether another page is available + def more? + return true unless @walk.buffered.nil? + + page = @walk.fetch_next_page + return false if page.nil? + + @walk.buffer(page) + true + end + + # Releases both the held page and any staged one, surfacing a close failure rather than + # swallowing it (PAGE-15); idempotent through the walk's latch. + # + # @return [nil] + def close + @walk.close + end + + # Whether #close has run (or #each's own ensure did). + # + # @return [Boolean] + def closed? + @walk.closed? + end + + private + + def claim! + if @viewed + raise PageStateError, + "a page view is single-use: its iterator was already obtained (PAGE-14)" + end + + @viewed = true + end + + # The one drive: a staged page first, else the next fetched one; hold it (closing the previous + # -- a failure there is surfaced, PAGE-15), yield it; close the walk on every exit through + # Closing, with the consumer's error recorded so a close failure joins its trail instead of + # replacing it (PAGE-13's order, R8). + def drive + primary = nil #: Exception? + begin + while (page = @walk.take_buffered || @walk.fetch_next_page) + @walk.hold(page) + yield page + end + rescue ::Exception => error # rubocop:disable Lint/RescueException -- recorded for the ensure's Closing route, re-raised unchanged + primary = error + raise + ensure + Closing.close_walk(@walk, primary) + end + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/paginator.rb b/gems/dexpace-core/lib/dexpace/page/paginator.rb new file mode 100644 index 0000000..ed5894e --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/paginator.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../model" +require_relative "../cancellation" +require_relative "../http/request" +require_relative "../http/request_options" +require_relative "closing" +require_relative "walk" +require_relative "items" +require_relative "pages" + +module Dexpace + class Page + # The blocking pagination engine (PAGE-6 through PAGE-10, PAGE-36): a frozen Data holding only + # immutable configuration -- the transport, the request template, the strategy, the page cap + # and the per-call options -- and no per-walk state at all, so one instance is safe to share + # across any number of concurrent iterations (PAGE-8). Every per-walk quantity lives on a + # private Walk that #items and #pages allocate per view; nothing here is fetched until a + # consumer probes (PAGE-6). + # + # The transport is anything answering `#call(request, options, cancellation)` with a + # Dexpace::Response -- a Pipeline, a bare adapter, a lambda. A built pipeline is a transport + # whose #close is a no-op (PIPE-26, PIPE-27), so the paginator wraps one without declaring it + # and NEVER closes it: PAGE-3's ownership transfer is about the response, not the transport it + # came from, and a paginator that closed its transport would break a caller who built one + # pipeline for a whole client. The per-call options are passed on EVERY exchange, which is + # what makes PAGE-36 structural rather than remembered; the default is RequestOptions::EMPTY, + # "no overrides". The cancellation the transport receives is always Cancellation.none: the + # blocking engine takes no per-walk token in v1, and a nil there would die inside the standard + # pipeline's retry step (P7-102). + # + # PAGE-10: the default cap is Float::INFINITY, strictly positive and comparable against an + # Integer exchange count, so the cap check is one expression with no nil branch. **Production + # callers should set a finite cap**: an unbounded walk over a server that never advances its + # cursor runs until the process is stopped, and the cap is the only bound this engine has. + class Paginator < ::Data.define(:transport, :template, :strategy, :cap, :options) + include Model + + private_class_method :new + + # @param transport [#call] `#call(request, options, cancellation) -> Dexpace::Response` + # @param template [Dexpace::Request] the first page's request and the shape every later one + # is derived from + # @param strategy [_Strategy] a stateless parser answering `#parse(response, template)` + # @param cap [Numeric] PAGE-9's maximum number of exchanges, strictly positive; set a finite + # one in production (PAGE-10) + # @param options [Dexpace::RequestOptions] PAGE-36's per-call overrides, applied to every page + # @return [Dexpace::Page::Paginator] frozen + # @raise [Dexpace::InvalidArgumentError] when a member is missing or of the wrong shape, or + # the cap is not strictly positive + def self.build(transport:, template:, strategy:, cap: ::Float::INFINITY, + options: Dexpace::RequestOptions::EMPTY) + new(transport: transport, template: template, strategy: strategy, cap: cap, + options: options,) + end + + # Validation lives here so .build, #with and a forged send(:new, ...) all meet it (PAGE-9's + # "validated as strictly positive at construction, not lazily"). + def initialize(transport:, template:, strategy:, cap:, options:) + validate_engine!(transport, template, strategy) + unless cap.is_a?(::Numeric) && cap.positive? + raise InvalidArgumentError, + "cap must be a strictly positive number, got #{cap.inspect} (PAGE-9)" + end + unless Model.required!("options", options).is_a?(Dexpace::RequestOptions) + raise InvalidArgumentError, + "options must be a Dexpace::RequestOptions, got #{options.class}" + end + + super + end + + # The item-level view: a fresh walk per iteration, every page closed before its items are + # yielded (PAGE-11), re-iterable (PAGE-8). Nothing is fetched until it is driven (PAGE-6). + # + # @return [Dexpace::Page::Items] + def items + Items.new(method(:open_walk)) + end + + # The page-level view: one walk, single-use (PAGE-14), auto-closing with a one-slot look-ahead + # (PAGE-12). Nothing is fetched until it is driven or probed (PAGE-6). Wrap it in #each_page, + # or call Pages#close yourself, so an early exit releases what it holds. + # + # @return [Dexpace::Page::Pages] + def pages + Pages.new(method(:open_walk)) + end + + # The scoped opener for items: yields each item in server order and closes the walk on every + # exit -- a return, a `break`, a raise. The recommended form (PAGE-12's "consumers MUST be + # told to wrap the view in a scoped/auto-close construct"). + # + # @yieldparam item [Object] + # @return [nil] + # @raise [Dexpace::InvalidArgumentError] without a block + def each_item(&block) + raise InvalidArgumentError, "each_item requires a block" if block.nil? + + items.each(&block) + nil + end + + # The scoped opener for pages: yields each live Dexpace::Page in order, closes the previous + # page as the consumer advances, the last at exhaustion, and both held pages on every exit -- + # a return, a `break`, a raise. The recommended form for the page view (PAGE-12). + # + # @yieldparam page [Dexpace::Page] + # @return [nil] + # @raise [Dexpace::InvalidArgumentError] without a block + def each_page(&block) + raise InvalidArgumentError, "each_page requires a block" if block.nil? + + pages.each(&block) + nil + end + + # The strategy engine's per-walk exchange routine (P7-111): the next request is its one piece + # of state, seeded from the template and replaced by each Info's next request; nil once the + # strategy said end-of-stream, with no exchange (PAGE-7). One exchange passes the paginator's + # options every time (PAGE-36) and Cancellation.none (P7-102), then PAGE-13's parse-or-close, + # then the Page that owns the response from here on (PAGE-3). + class Drive + # @param paginator [Dexpace::Page::Paginator] + def initialize(paginator) + @paginator = paginator + @next_request = paginator.template #: Dexpace::Request? + end + + # @return [Dexpace::Page, nil] + def call + request = @next_request + return nil if request.nil? + + response = @paginator.transport.call(request, @paginator.options, Dexpace::Cancellation.none) + info = Closing.parse_or_close(@paginator.strategy, response, @paginator.template) + @next_request = info.next_request + Dexpace::Page.build(response: response, items: info.items, next_link: info.next_link, + continuation_token: info.continuation_token,) + end + end + private_constant :Drive + + private + + # The three members named the way HTTP-4 / SEAM-29 name them; the async engine validates the + # same three the same way. + def validate_engine!(transport, template, strategy) + unless Model.required!("transport", transport).respond_to?(:call) + raise InvalidArgumentError, "transport must respond to #call, got #{transport.class}" + end + unless Model.required!("template", template).is_a?(Dexpace::Request) + raise InvalidArgumentError, "template must be a Dexpace::Request, got #{template.class}" + end + return if Model.required!("strategy", strategy).respond_to?(:parse) + + raise InvalidArgumentError, "strategy must respond to #parse, got #{strategy.class}" + end + + # A fresh Walk over a fresh Drive: PAGE-8's "each independent iteration restarts from the + # initial request with its own fresh state". + def open_walk + Walk.new(Drive.new(self), cap: cap) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/query_rewriter.rb b/gems/dexpace-core/lib/dexpace/page/query_rewriter.rb new file mode 100644 index 0000000..e1dd897 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/query_rewriter.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../http/percent_encoding" + +module Dexpace + class Page + # PAGE-21 through PAGE-24: the verbatim query splice. Three pure functions over the RAW query + # substring, never over a parsed model. + # + # The whole design of this module is PAGE-21's byte-for-byte rule: it tokenises on `&` and + # on the FIRST `=` of each segment, copies every untargeted segment as exactly the bytes it + # found -- a value-less flag stays value-less, `filter=a:b` keeps its bare colon, an empty + # segment stays empty -- and decodes or encodes only the targeted parameter's name and value. + # It never round-trips the whole query. Phase 1's Dexpace::Query and Query::Builder are + # correct for what they are and deliberately not used here: `Query.parse(q).encode` + # re-encodes every parameter, rewriting `filter=a:b` to `filter=a%3Ab`, which is the + # canonicalisation PAGE-21 forbids in as many words. Ruby's own helpers are rejected by + # measurement rather than taste: URI.decode_www_form reads `a+b` as "a b" and + # URI.encode_www_form_component writes a space as `+`, the exact inverse of PAGE-22 in both + # directions. The codec is phase 1's PercentEncoding -- RFC 3986 component encoding, space to + # `%20`, a literal `+` to `%2B` and back to `+` -- and nothing new is encoded here (PAGE-22). + # + # Two measured facts hold the URL half up (matrix_facts_test.rb): `URI::Generic#query=` is + # byte-transparent for every query string the pinned parser can produce -- the canonicalising + # happens at PARSE time, before a Request#url ever reaches this module -- so `dup`, `query=` + # and the model's re-parse round-trip the splice exactly, and every other component survives + # untouched (PAGE-24); and `query = nil` removes the query while `query = ""` leaves a dangling + # `?`, which is why an empty splice is normalised to nil (P7-4). + module QueryRewriter + extend self + + # PAGE-22's reading half: the first matching parameter's value, decoded with RFC 3986 + # semantics -- `%20` reads as a space, a literal `+` as `+`, a value-less flag as "" -- or + # nil when the parameter is absent. The name is matched decoded, so `pa%67e` and `page` are + # one parameter. + # + # @param query [String, nil] the raw query substring, without the `?` + # @param name [String] the parameter to read + # @return [String, nil] + def get(query, name) + segments(query).each do |segment| + raw_name, raw_value = split_segment(segment) + next unless decode(raw_name) == name + + return raw_value.nil? ? "" : decode(raw_value) + end + nil + end + + # PAGE-23: replace the first existing occurrence in place, drop every later duplicate, + # append when absent, and remove entirely when `value` is nil; order is otherwise + # preserved and every untargeted segment is copied byte-for-byte (PAGE-21). The new + # name and value are RFC 3986 component-encoded (PAGE-22). An empty result is nil rather + # than "" so the URL it is spliced into carries no dangling `?` (P7-4). + # + # @param query [String, nil] the raw query substring, without the `?` + # @param name [String] the parameter to set + # @param value [String, nil] the new value; nil removes the parameter + # @return [String, nil] the spliced query, or nil when nothing is left + def set(query, name, value) + replacement = value.nil? ? nil : "#{encode(name)}=#{encode(value)}" + out, seen = splice(segments(query), name, replacement) + out << replacement if !seen && !replacement.nil? + out.empty? ? nil : out.join("&") + end + + # PAGE-24: a copy of `uri` with only its query changed, through .set. Every other component + # -- scheme, userinfo, host, port, path, fragment -- is untouched because none is visited; + # the caller's URI, frozen or not, is never written to. + # + # @param uri [URI::Generic] the template's URL + # @param name [String] the parameter to set + # @param value [String, nil] the new value; nil removes the parameter + # @return [URI::Generic] a fresh URI, unfrozen, for Request#with to re-parse and own + def rewrite_url(uri, name, value) + copy = uri.dup + copy.query = set(copy.query, name, value) + copy + end + + private + + # One pass over the segments: the first match becomes `replacement` (or vanishes when it is + # nil), every later match is dropped, everything else is copied as the bytes it was. + # + # @return [Array(Array, Boolean)] the output segments, and whether a match was seen + def splice(segments, name, replacement) + out = [] #: Array[String] + seen = false + segments.each do |segment| + raw_name, = split_segment(segment) + if decode(raw_name) == name + out << replacement unless seen || replacement.nil? + seen = true + else + out << segment + end + end + [out, seen] + end + + # `split("&", -1)` keeps empty segments -- `a&&b` and a trailing `&` are bytes PAGE-21 + # preserves -- and an absent or empty query has no segments at all. + def segments(query) + return [] if query.nil? || query.empty? + + query.split("&", -1) + end + + # The first `=` only: `a=b=c` is the name `a` with the value `b=c`. + def split_segment(segment) + index = segment.index("=") + return [segment, nil] if index.nil? + + [segment[0, index] || "", segment[(index + 1)..] || ""] + end + + def decode(text) = Dexpace::PercentEncoding.decode_component(text) + def encode(text) = Dexpace::PercentEncoding.encode_component(text) + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/page/walk.rb b/gems/dexpace-core/lib/dexpace/page/walk.rb new file mode 100644 index 0000000..740590b --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/walk.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../page" +require_relative "../closeable" +require_relative "../suppressible" +require_relative "page_state_error" + +module Dexpace + class Page + # The per-iteration lifetime owner: every live page a walk holds is an instance variable on + # this object, which the caller can reach and #close -- never a local of an Enumerator block or + # of a #each method. That is design §7.1's hard rule applied literally: an Enumerator abandoned + # mid-#next never runs its ensure, neither does an ordinary #each, and block_given? is + # measurably true inside #each when reached through to_enum(:each), so no in-method guard + # defends anything; the only defence is where the resource lives (pagination/318ae05d, + # pagination/b2a85752). Dexpace::Closeable's latch is what phase 2 built for exactly this. + # + # One drive routine, shared by both views (pagination/71aed9c1) and generic over a per-walk + # DRIVE -- an object answering `#call -> Dexpace::Page | nil` that performs one exchange and + # holds whatever state the next one needs. The strategy engine's drive holds the next request; + # the fetcher front-end's holds the previous page's key (P7-111). The walk owns what is common: + # the cap counted in exchanges (PAGE-9), the exhaustion latch that makes end-of-stream probes + # idempotent and free (PAGE-7), the current and the one-slot look-ahead page (PAGE-12), and the + # release discipline for both (PAGE-15). Not shareable: one walk per iteration (PAGE-8). + # + # A private_constant, asserted through the views and reached by const_get in its own suite, + # with a sig/ mirror because the strict `core` Steep target types every file under lib/. + class Walk + include Dexpace::Closeable + + # @param drive [#call] the per-walk exchange routine, `#call -> Dexpace::Page | nil` + # @param cap [Numeric] PAGE-9's page cap, counted in exchanges, validated by the engine + def initialize(drive, cap:) + @drive = drive + @cap = cap + @current = nil + @buffered = nil + @exchanges = 0 + @exhausted = false + initialize_closeable(owned: true) + end + + # @return [Dexpace::Page, nil] the page handed to the consumer and not yet advanced past + attr_reader :current + # @return [Dexpace::Page, nil] PAGE-12's one-slot look-ahead, filled by a probe + attr_reader :buffered + + # The one drive routine. nil after the walk is closed (a closed walk owns no slot to hold a + # response in, so a late probe acquires nothing), nil with no exchange once exhausted + # (PAGE-7), nil and latched when the cap is reached -- checked BEFORE the exchange, so a cap + # of N produces exactly N exchanges (PAGE-9); otherwise one exchange through the drive, whose + # nil is the strategy's end-of-stream (PAGE-4). The returned page is the caller's to hold. + # + # @return [Dexpace::Page, nil] + def fetch_next_page + return nil if closed? || @exhausted + return latch_exhausted if @exchanges >= @cap + + page = @drive.call + return latch_exhausted if page.nil? + + @exchanges += 1 + page + end + + # PAGE-12's advance: `page` becomes the held page and the previous one is closed. The new + # page is written into the slot BEFORE the previous one is closed, so a close error strands + # nothing -- the frame that owns the walk closes it on the way out and finds the new page + # held. The close is bare: when the consumer has returned from the previous yield nothing is + # in flight, so a close error here is SURFACED and never swallowed (PAGE-15's first clause + # does not carve out the advance), and never read off `$!`, which may be a caller's + # unrelated in-flight error (P7-105). + # + # @param page [Dexpace::Page] the page about to be yielded + # @return [nil] + def hold(page) + previous = @current + @current = page + previous.close unless previous.nil? || previous.equal?(page) + nil + end + + # PAGE-12's look-ahead. ONE slot: a staged page is never displaced, because overwriting it + # would spend a second exchange on one yielded page (PAGE-6) and strand the displaced + # response, which is the leak the buffering clause exists to prevent -- a probe that finds a + # page staged reads it instead (Pages#more?), so a second call here is a state error. + # + # @param page [Dexpace::Page] the fetched-but-undelivered page + # @return [nil] + # @raise [Dexpace::Page::PageStateError] when a page is already staged + def buffer(page) + unless @buffered.nil? + raise PageStateError, + "a page is already staged in the walk's look-ahead slot" + end + + @buffered = page + nil + end + + # Empties the look-ahead slot and hands its page over to be held. + # + # @return [Dexpace::Page, nil] + def take_buffered + page = @buffered + @buffered = nil + page + end + + private + + def latch_exhausted + @exhausted = true + nil + end + + # PAGE-15's third clause and PAGE-12's "explicit close MUST release both": both slots are + # released, the first close failure is held, a second is attached to it as suppressed, and + # the first is re-raised -- `raise error, cause: nil`, because it is being carried past a + # later rescue (pipeline/7ce4431d). Both slots are cleared before anything can raise, so the + # latch and the slots agree whatever happens; the attach is a no-op on a frozen primary + # (phase 4b's P4-13), stated rather than worked around. + def release + pages = [@current, @buffered].compact + @current = nil + @buffered = nil + first = nil #: StandardError? + pages.each do |page| + page.close + rescue ::StandardError => error + first.nil? ? first = error : Dexpace.attach_suppressed(first, error) + end + raise first, cause: nil unless first.nil? + + nil + end + end + private_constant :Walk + end +end diff --git a/gems/dexpace-core/sig/dexpace/http/url.rbs b/gems/dexpace-core/sig/dexpace/http/url.rbs index 5c0f4a4..11d80e3 100644 --- a/gems/dexpace-core/sig/dexpace/http/url.rbs +++ b/gems/dexpace-core/sig/dexpace/http/url.rbs @@ -2,6 +2,7 @@ module Dexpace module URL def self?.parse!: (String | URI::Generic input) -> URI::Generic def self?.external_form: (URI::Generic uri) -> String + def self?.resolve: (String | URI::Generic base, String reference) -> URI::Generic? private diff --git a/gems/dexpace-core/sig/dexpace/page.rbs b/gems/dexpace-core/sig/dexpace/page.rbs new file mode 100644 index 0000000..0508a2d --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page.rbs @@ -0,0 +1,56 @@ +module Dexpace + # PAGE-2 / PAGE-3: one page of a paginated walk, owning exactly one live Response behind + # Dexpace::Closeable's latch; also the namespace of the pagination subsystem (P7-2). The three + # duck types the subsystem takes are declared here, nested, the way io.rbs holds _Source and + # http/body.rbs holds _ResponseHandler: validated at construction with respond_to?, never by a + # nominal test. + class Page + include Dexpace::Closeable + + # PAGE-4 / PAGE-5: a stateless parser from a response and the original template to an Info. + interface _Strategy + def parse: (Dexpace::Response response, Dexpace::Request template) -> Dexpace::Page::Info + end + + # R7 / P7-6: what the built-in strategies are handed instead of a codec. The cursor strategy + # expects `[items, cursor_or_nil]`; the page-number and Link strategies expect the items alone. + interface _Extractor + def call: (Dexpace::Response response) -> untyped + end + + # PAGE-29's caller-supplied executor: one fire-and-forget method. Phase 8's + # dexpace-async-thread is the first real implementation; core spawns nothing (R9). + interface _Executor + def post: () { () -> void } -> void + end + + @response: Dexpace::Response + @items: Array[untyped] + @next_link: String? + @continuation_token: String? + + attr_reader response: Dexpace::Response + attr_reader items: Array[untyped] + attr_reader next_link: String? + attr_reader continuation_token: String? + + private def self.new: (Dexpace::Response response, Array[untyped] items, String? next_link, String? continuation_token) -> instance + def initialize: (Dexpace::Response response, Array[untyped] items, String? next_link, String? continuation_token) -> void + + def self.build: (response: Dexpace::Response, items: Array[untyped], ?next_link: String?, ?continuation_token: String?) -> Dexpace::Page + def self.next_request_from: (Dexpace::Request template, Dexpace::Response response, String? target) -> Dexpace::Request? + + def status: () -> Dexpace::Status + def headers: () -> Dexpace::Headers + def request: () -> Dexpace::Request + + private + + DISPATCHABLE_SCHEMES: Array[String] + + def self.same_document?: (String target) -> bool + def self.dispatchable?: (URI::Generic target) -> bool + def optional_string: (String name, untyped value) -> String? + def release: () -> void + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/async_paginator.rbs b/gems/dexpace-core/sig/dexpace/page/async_paginator.rbs new file mode 100644 index 0000000..640e8d9 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/async_paginator.rbs @@ -0,0 +1,66 @@ +module Dexpace + class Page + # PAGE-25..PAGE-33: the non-blocking engine, a frozen Data of immutable configuration plus an + # optional executor. `transport`, `strategy` and `executor` are untyped because a lambda is a + # valid transport, a strategy is any object answering #parse and an executor any answering + # #post (respond_to? validates all three). + class AsyncPaginator < Data + include Model + + attr_reader transport: untyped + attr_reader template: Dexpace::Request + attr_reader strategy: untyped + attr_reader cap: Numeric + attr_reader options: Dexpace::RequestOptions + attr_reader executor: untyped + + private def self.new: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions, executor: untyped) -> instance + def initialize: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions, executor: untyped) -> void + + def self.build: (transport: untyped, template: Dexpace::Request, strategy: untyped, ?cap: Numeric, ?options: Dexpace::RequestOptions, ?executor: untyped) -> AsyncPaginator + + def walk: (untyped consumer, ?cancellation: Dexpace::Cancellation?) -> Dexpace::Async::Future + def walk_pages: (untyped consumer, ?cancellation: Dexpace::Cancellation?) -> Dexpace::Async::Future + + # The per-walk trampoline (PAGE-31): a private_constant, declared here because the strict + # `core` Steep target types its call sites. + class Pump + @paginator: AsyncPaginator + @consumer: untyped + @cancellation: Dexpace::Cancellation + @mode: Symbol + @completer: Dexpace::Async::Completer + @next_request: Dexpace::Request? + @exchanges: Integer + @pages: Integer + @in_flight: Dexpace::Async::Future? + @mutex: Thread::Mutex + @running: bool + @rearm: bool + + def initialize: (AsyncPaginator paginator, untyped consumer, Dexpace::Cancellation cancellation, Symbol mode) -> void + def start: () -> Dexpace::Async::Future + + private + + def resume: () -> void + def claim: (Symbol step) -> bool + def launch: () -> void + def deliver: (Dexpace::Async::Settlement settlement) -> void + def submit: (untyped response) { () -> void } -> void + def settled: (Dexpace::Async::Settlement settlement) -> void + def null_success: () -> bool + def forward: (Dexpace::Async::Settlement settlement) -> void + def build_page: (Dexpace::Response response) -> Dexpace::Page + def drain: (Dexpace::Page page) -> void + def advance: () -> void + def guarded: () { () -> void } -> void + end + + private + + def validate_engine!: (untyped transport, untyped template, untyped strategy) -> void + def start: (untyped consumer, Dexpace::Cancellation? cancellation, Symbol mode) -> Dexpace::Async::Future + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/closing.rbs b/gems/dexpace-core/sig/dexpace/page/closing.rbs new file mode 100644 index 0000000..2d6083e --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/closing.rbs @@ -0,0 +1,12 @@ +# Dexpace::Page::Closing is a private_constant and not public API: this declaration exists +# because the strict `core` Steep target checks every file under lib/ and needs the module and +# its two functions declared to type the views', the engines' and the async pump's call sites. +# RBS has no visibility for a constant, so the privacy lives in lib/dexpace/page/closing.rb alone. +module Dexpace + class Page + module Closing + def self?.close_walk: (Dexpace::Page::Walk walk, Exception? primary) -> nil + def self?.parse_or_close: (untyped strategy, Dexpace::Response response, Dexpace::Request template) -> Dexpace::Page::Info + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/cursor_strategy.rbs b/gems/dexpace-core/sig/dexpace/page/cursor_strategy.rbs new file mode 100644 index 0000000..1acbc02 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/cursor_strategy.rbs @@ -0,0 +1,22 @@ +module Dexpace + class Page + # PAGE-16: the cursor strategy over a caller-supplied `#call(response) -> [items, cursor]` + # extractor (R7, P7-6). `extract` is untyped because a lambda is a valid value. + class CursorStrategy < Data + include Model + + attr_reader extract: untyped + attr_reader parameter: String + + private def self.new: (extract: untyped, parameter: String) -> instance + def initialize: (extract: untyped, parameter: String) -> void + + def self.build: (extract: untyped, ?parameter: String) -> CursorStrategy + def parse: (Dexpace::Response response, Dexpace::Request template) -> Info + + private + + def unpack: (untyped answer) -> [Array[untyped], String?] + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/fetchers.rbs b/gems/dexpace-core/sig/dexpace/page/fetchers.rbs new file mode 100644 index 0000000..48387e9 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/fetchers.rbs @@ -0,0 +1,45 @@ +module Dexpace + class Page + # PAGE-34 / PAGE-35: the fetcher-based front-end, a frozen Data over two caller callables and + # the options threaded to them by identity. Both fetchers are untyped because a lambda is the + # ordinary value (respond_to? validates them; Registry.callable? reads their arity). + class Fetchers < Data + include Model + + attr_reader first: untyped + attr_reader next_page: untyped + attr_reader options: Dexpace::RequestOptions + + private def self.new: (first: untyped, next_page: untyped, options: Dexpace::RequestOptions) -> instance + def initialize: (first: untyped, next_page: untyped, options: Dexpace::RequestOptions) -> void + + def self.build: (first: untyped, next_page: untyped, ?options: Dexpace::RequestOptions) -> Fetchers + + def items: () -> Items + def pages: () -> Pages + def each_item: () { (untyped) -> void } -> nil + def each_page: () { (Dexpace::Page) -> void } -> nil + + # The fetcher front-end's private per-walk drive (P7-111): a private_constant, declared here + # because the strict `core` Steep target types its call sites. + class Drive + @fetchers: Fetchers + @started: bool + @key: String? + + def initialize: (Fetchers fetchers) -> void + def call: () -> Dexpace::Page? + + private + + def fetch_first: () -> untyped + def fetch_next: () -> untyped + def key_of: (Dexpace::Page page) -> String? + end + + private + + def open_walk: () -> Dexpace::Page::Walk + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/info.rbs b/gems/dexpace-core/sig/dexpace/page/info.rbs new file mode 100644 index 0000000..a4f19ca --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/info.rbs @@ -0,0 +1,23 @@ +module Dexpace + class Page + # PAGE-4's PageInfo: items plus the next request, whose nil is the one end-of-stream signal. + class Info < Data + include Model + + attr_reader items: Array[untyped] + attr_reader next_request: Dexpace::Request? + attr_reader next_link: String? + attr_reader continuation_token: String? + + private def self.new: (items: Array[untyped], next_request: Dexpace::Request?, next_link: String?, continuation_token: String?) -> instance + def initialize: (items: Array[untyped], next_request: Dexpace::Request?, next_link: String?, continuation_token: String?) -> void + + def self.build: (items: Array[untyped], next_request: Dexpace::Request?, ?next_link: String?, ?continuation_token: String?) -> Info + def self.terminal: (?items: Array[untyped]) -> Info + + private + + def optional_string: (String name, untyped value) -> String? + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/items.rbs b/gems/dexpace-core/sig/dexpace/page/items.rbs new file mode 100644 index 0000000..de68a92 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/items.rbs @@ -0,0 +1,16 @@ +module Dexpace + class Page + # PAGE-1 / PAGE-8 / PAGE-11: the re-iterable item-level view, one fresh walk per #each, every + # page closed before its items are yielded. Enumerable's vocabulary drives #each. + class Items + include ::Enumerable[untyped] + + @opener: untyped + + def initialize: (untyped opener) -> void + def each: () { (untyped) -> void } -> self + | () -> ::Enumerator[untyped, self] + def close: () -> nil + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/link_header.rbs b/gems/dexpace-core/sig/dexpace/page/link_header.rbs new file mode 100644 index 0000000..c1c1397 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/link_header.rbs @@ -0,0 +1,26 @@ +# Dexpace::Page::LinkHeader is a private_constant and not public API: this declaration exists +# because the strict `core` Steep target checks every file under lib/ and needs the module and +# its one function declared to type LinkStrategy's call site. RBS has no visibility for a +# constant, so the privacy lives in lib/dexpace/page/link_header.rb alone. +module Dexpace + class Page + module LinkHeader + OWS: Array[String] + SEPARATORS: Array[String] + TOKEN_END: Array[String] + + def self?.next_target: (Array[String]? values) -> String? + + private + + def next_token?: (String value) -> bool + def link_values: (String text) -> Array[[String, Array[[String, String]]]] + def skip: (String text, Integer index, Array[String] set) -> Integer + def parse_params: (String text, Integer index) -> [Array[[String, String]], Integer] + def parse_param: (String text, Integer index) -> [[String, String]?, Integer] + def read_value: (String text, Integer index) -> [String?, Integer] + def read_token: (String text, Integer index) -> [String, Integer] + def read_quoted: (String text, Integer index) -> [String?, Integer] + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/link_strategy.rbs b/gems/dexpace-core/sig/dexpace/page/link_strategy.rbs new file mode 100644 index 0000000..28df566 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/link_strategy.rbs @@ -0,0 +1,18 @@ +module Dexpace + class Page + # PAGE-18..PAGE-20: the Link-header strategy over a caller-supplied `#call(response) -> items` + # extractor; the next request comes from the header through Page.next_request_from. + class LinkStrategy < Data + include Model + + attr_reader extract_items: untyped + attr_reader header: String + + private def self.new: (extract_items: untyped, header: String) -> instance + def initialize: (extract_items: untyped, header: String) -> void + + def self.build: (extract_items: untyped, ?header: String) -> LinkStrategy + def parse: (Dexpace::Response response, Dexpace::Request template) -> Info + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/page_number_strategy.rbs b/gems/dexpace-core/sig/dexpace/page/page_number_strategy.rbs new file mode 100644 index 0000000..8cd76dc --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/page_number_strategy.rbs @@ -0,0 +1,24 @@ +module Dexpace + class Page + # PAGE-17: the page-number strategy over a caller-supplied `#call(response) -> items` extractor. + class PageNumberStrategy < Data + include Model + + DIGITS: Regexp + + attr_reader extract_items: untyped + attr_reader parameter: String + attr_reader start: Integer + + private def self.new: (extract_items: untyped, parameter: String, start: Integer) -> instance + def initialize: (extract_items: untyped, parameter: String, start: Integer) -> void + + def self.build: (extract_items: untyped, ?parameter: String, ?start: Integer) -> PageNumberStrategy + def parse: (Dexpace::Response response, Dexpace::Request template) -> Info + + private + + def current_page: (Dexpace::Response response) -> Integer + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/page_state_error.rbs b/gems/dexpace-core/sig/dexpace/page/page_state_error.rbs new file mode 100644 index 0000000..ace0b8e --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/page_state_error.rbs @@ -0,0 +1,8 @@ +module Dexpace + class Page + # PAGE-14: a second #each on a single-use page view; a state error, never an ArgumentError. + class PageStateError < ::StandardError + include Dexpace::Error + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/pages.rbs b/gems/dexpace-core/sig/dexpace/page/pages.rbs new file mode 100644 index 0000000..ffb97aa --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/pages.rbs @@ -0,0 +1,24 @@ +module Dexpace + class Page + # PAGE-1 / PAGE-12 / PAGE-14 / PAGE-15: the single-use, auto-closing page-level view over one + # walk with a one-slot look-ahead. Enumerable's vocabulary drives #each, the one use. + class Pages + include ::Enumerable[Dexpace::Page] + + @walk: Dexpace::Page::Walk + @viewed: bool + + def initialize: (untyped opener) -> void + def each: () { (Dexpace::Page) -> void } -> self + | () -> ::Enumerator[Dexpace::Page, self] + def more?: () -> bool + def close: () -> nil + def closed?: () -> bool + + private + + def claim!: () -> void + def drive: () { (Dexpace::Page) -> void } -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/paginator.rbs b/gems/dexpace-core/sig/dexpace/page/paginator.rbs new file mode 100644 index 0000000..bdb9776 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/paginator.rbs @@ -0,0 +1,41 @@ +module Dexpace + class Page + # PAGE-6..PAGE-10, PAGE-36: the blocking engine, a frozen Data of immutable configuration. + # `transport` and `strategy` are untyped because a lambda is a valid transport and a strategy is + # any object answering #parse (respond_to? validates both). + class Paginator < Data + include Model + + attr_reader transport: untyped + attr_reader template: Dexpace::Request + attr_reader strategy: untyped + attr_reader cap: Numeric + attr_reader options: Dexpace::RequestOptions + + private def self.new: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions) -> instance + def initialize: (transport: untyped, template: Dexpace::Request, strategy: untyped, cap: Numeric, options: Dexpace::RequestOptions) -> void + + def self.build: (transport: untyped, template: Dexpace::Request, strategy: untyped, ?cap: Numeric, ?options: Dexpace::RequestOptions) -> Paginator + + def items: () -> Items + def pages: () -> Pages + def each_item: () { (untyped) -> void } -> nil + def each_page: () { (Dexpace::Page) -> void } -> nil + + # The strategy engine's private per-walk drive (P7-111): a private_constant, declared here + # because the strict `core` Steep target types its call sites. + class Drive + @paginator: Paginator + @next_request: Dexpace::Request? + + def initialize: (Paginator paginator) -> void + def call: () -> Dexpace::Page? + end + + private + + def validate_engine!: (untyped transport, untyped template, untyped strategy) -> void + def open_walk: () -> Dexpace::Page::Walk + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/query_rewriter.rbs b/gems/dexpace-core/sig/dexpace/page/query_rewriter.rbs new file mode 100644 index 0000000..279fe27 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/query_rewriter.rbs @@ -0,0 +1,18 @@ +module Dexpace + class Page + # PAGE-21..PAGE-24: the verbatim query splice over the raw query substring. + module QueryRewriter + def self?.get: (String? query, String name) -> String? + def self?.set: (String? query, String name, String? value) -> String? + def self?.rewrite_url: (URI::Generic uri, String name, String? value) -> URI::Generic + + private + + def splice: (Array[String] segments, String name, String? replacement) -> [Array[String], bool] + def segments: (String? query) -> Array[String] + def split_segment: (String segment) -> [String, String?] + def decode: (String text) -> String + def encode: (String text) -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/page/walk.rbs b/gems/dexpace-core/sig/dexpace/page/walk.rbs new file mode 100644 index 0000000..485bdb1 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page/walk.rbs @@ -0,0 +1,32 @@ +# Dexpace::Page::Walk is a private_constant and not public API: this declaration exists because +# the strict `core` Steep target checks every file under lib/ and needs the class, its ivars and +# its readers declared to type the two views and the two engines. RBS has no visibility for a +# constant, so the privacy lives in lib/dexpace/page/walk.rb alone. +module Dexpace + class Page + class Walk + include Dexpace::Closeable + + @drive: untyped + @cap: Numeric + @current: Dexpace::Page? + @buffered: Dexpace::Page? + @exchanges: Integer + @exhausted: bool + + attr_reader current: Dexpace::Page? + attr_reader buffered: Dexpace::Page? + + def initialize: (untyped drive, cap: Numeric) -> void + def fetch_next_page: () -> Dexpace::Page? + def hold: (Dexpace::Page page) -> nil + def buffer: (Dexpace::Page page) -> nil + def take_buffered: () -> Dexpace::Page? + + private + + def latch_exhausted: () -> nil + def release: () -> nil + end + end +end diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index b813b6a..02630f3 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -80,9 +80,12 @@ class DexpaceTest < DexpaceTestCase # PhaseSevenLayers case below pins, and its two errors are namespaced there (SSE-37 makes the # subsystem self-contained, and nothing outside it raises either). SSE_LAYER = %i[SSE].freeze + # Phase 7c: ONE flat constant, the page value that is also the subsystem's namespace (P7-2); + # everything else the layer ships is under Dexpace::Page, which the PhaseSevenLayers case pins. + PAGE_LAYER = %i[Page].freeze LAYERS = [ DOMAIN_MODEL, SEAM_LAYER, IO_LAYER, BODY_LAYER, CONTEXT_LAYER, RECOVERY_LAYER, PIPELINE_LAYER, - CONFIGURATION_LAYER, RESILIENCE_LAYER, AUTH_LAYER, REDIRECT_LAYER, SSE_LAYER, + CONFIGURATION_LAYER, RESILIENCE_LAYER, AUTH_LAYER, REDIRECT_LAYER, SSE_LAYER, PAGE_LAYER, ].flatten.freeze test "defines nothing outside the Dexpace namespace" do @@ -284,6 +287,37 @@ class PhaseSevenLayers < DexpaceTestCase assert_raises(::NameError) { Dexpace::SSE::Reader::DIGITS_ONLY } assert_raises(::NameError) { Dexpace::SSE::LimitExceededError::KINDS } end + + # A consumer requires "dexpace" and nothing else: the pagination layer resolves too (phase + # 7c), under Dexpace::Page -- the page value is the namespace (P7-2) -- with its three + # private_constants and the two engines' private drives as unreachable as Dexpace::Hooks, and + # none of the nested names defined at the top level or flat under Dexpace (P3-7's shadowing + # audit, run rather than asserted). + test "requiring dexpace alone makes the whole pagination layer resolve, its helpers private" do + page = Dexpace::Page + + assert_equal( + %i[ + AsyncPaginator CursorStrategy Fetchers Info Items LinkStrategy PageNumberStrategy + PageStateError Pages Paginator QueryRewriter + ], + page.constants(false).sort, + ) + assert_kind_of(Dexpace::Closeable, page.allocate) + assert_raises(::NameError) { Dexpace::Page::Walk } + assert_raises(::NameError) { Dexpace::Page::Closing } + assert_raises(::NameError) { Dexpace::Page::LinkHeader } + assert_raises(::NameError) { Dexpace::Page::Paginator::Drive } + assert_raises(::NameError) { Dexpace::Page::Fetchers::Drive } + assert_raises(::NameError) { Dexpace::Page::AsyncPaginator::Pump } + %i[ + Info QueryRewriter LinkHeader CursorStrategy PageNumberStrategy LinkStrategy Walk Closing + Items Pages Paginator AsyncPaginator Fetchers PageStateError + ].each do |name| + refute(Object.const_defined?(name), "#{name} shadows a top-level constant") + refute(Dexpace.const_defined?(name, false), "#{name} shadows a flat Dexpace constant") + end + end end # The shadowing names this SDK never defines: each would make a bare `rescue ArgumentError`, diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page.rb b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page.rb new file mode 100644 index 0000000..44526cd --- /dev/null +++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + class Page + end +end diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page/info.rb b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page/info.rb new file mode 100644 index 0000000..597ff93 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page/info.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + class Page + class Info + end + end +end diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page.rbs b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page.rbs new file mode 100644 index 0000000..e217535 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page.rbs @@ -0,0 +1,4 @@ +module Dexpace + class Page + end +end diff --git a/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page/info.rbs b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page/info.rbs new file mode 100644 index 0000000..21445a6 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page/info.rbs @@ -0,0 +1,6 @@ +module Dexpace + class Page + class Info + end + end +end diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page.rb b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page.rb new file mode 100644 index 0000000..44526cd --- /dev/null +++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + class Page + end +end diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page/info.rb b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page/info.rb new file mode 100644 index 0000000..597ff93 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page/info.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + class Page + class Info + end + end +end diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page.rbs b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page.rbs new file mode 100644 index 0000000..e217535 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page.rbs @@ -0,0 +1,4 @@ +module Dexpace + class Page + end +end diff --git a/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page/info.rbs b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page/info.rbs new file mode 100644 index 0000000..21445a6 --- /dev/null +++ b/test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page/info.rbs @@ -0,0 +1,6 @@ +module Dexpace + class Page + class Info + end + end +end diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index 03e99b6..14f0a11 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -698,6 +698,82 @@ Dexpace::Outcome::Success#success? Dexpace::Outcome::Success.build Dexpace::OutcomeError Dexpace::OutcomeError#offending_class +Dexpace::Page +Dexpace::Page#continuation_token +Dexpace::Page#headers +Dexpace::Page#items +Dexpace::Page#next_link +Dexpace::Page#request +Dexpace::Page#response +Dexpace::Page#status +Dexpace::Page.build +Dexpace::Page.next_request_from +Dexpace::Page::AsyncPaginator +Dexpace::Page::AsyncPaginator#cap +Dexpace::Page::AsyncPaginator#executor +Dexpace::Page::AsyncPaginator#options +Dexpace::Page::AsyncPaginator#strategy +Dexpace::Page::AsyncPaginator#template +Dexpace::Page::AsyncPaginator#transport +Dexpace::Page::AsyncPaginator#walk +Dexpace::Page::AsyncPaginator#walk_pages +Dexpace::Page::AsyncPaginator.build +Dexpace::Page::CursorStrategy +Dexpace::Page::CursorStrategy#extract +Dexpace::Page::CursorStrategy#parameter +Dexpace::Page::CursorStrategy#parse +Dexpace::Page::CursorStrategy.build +Dexpace::Page::Fetchers +Dexpace::Page::Fetchers#each_item +Dexpace::Page::Fetchers#each_page +Dexpace::Page::Fetchers#first +Dexpace::Page::Fetchers#items +Dexpace::Page::Fetchers#next_page +Dexpace::Page::Fetchers#options +Dexpace::Page::Fetchers#pages +Dexpace::Page::Fetchers.build +Dexpace::Page::Info +Dexpace::Page::Info#continuation_token +Dexpace::Page::Info#items +Dexpace::Page::Info#next_link +Dexpace::Page::Info#next_request +Dexpace::Page::Info.build +Dexpace::Page::Info.terminal +Dexpace::Page::Items +Dexpace::Page::Items#close +Dexpace::Page::Items#each +Dexpace::Page::LinkStrategy +Dexpace::Page::LinkStrategy#extract_items +Dexpace::Page::LinkStrategy#header +Dexpace::Page::LinkStrategy#parse +Dexpace::Page::LinkStrategy.build +Dexpace::Page::PageNumberStrategy +Dexpace::Page::PageNumberStrategy#extract_items +Dexpace::Page::PageNumberStrategy#parameter +Dexpace::Page::PageNumberStrategy#parse +Dexpace::Page::PageNumberStrategy#start +Dexpace::Page::PageNumberStrategy.build +Dexpace::Page::PageStateError +Dexpace::Page::Pages +Dexpace::Page::Pages#close +Dexpace::Page::Pages#closed? +Dexpace::Page::Pages#each +Dexpace::Page::Pages#more? +Dexpace::Page::Paginator +Dexpace::Page::Paginator#cap +Dexpace::Page::Paginator#each_item +Dexpace::Page::Paginator#each_page +Dexpace::Page::Paginator#items +Dexpace::Page::Paginator#options +Dexpace::Page::Paginator#pages +Dexpace::Page::Paginator#strategy +Dexpace::Page::Paginator#template +Dexpace::Page::Paginator#transport +Dexpace::Page::Paginator.build +Dexpace::Page::QueryRewriter +Dexpace::Page::QueryRewriter#get +Dexpace::Page::QueryRewriter#rewrite_url +Dexpace::Page::QueryRewriter#set Dexpace::PercentEncoding Dexpace::PercentEncoding#decode_component Dexpace::PercentEncoding#encode_component @@ -1175,6 +1251,7 @@ Dexpace::TypedResponse#value Dexpace::URL Dexpace::URL#external_form Dexpace::URL#parse! +Dexpace::URL#resolve Dexpace::UUID Dexpace::UUID#generate Dexpace::VERSION : String diff --git a/test/gates/serde_boundary_test.rb b/test/gates/serde_boundary_test.rb index 8190e98..0df071b 100644 --- a/test/gates/serde_boundary_test.rb +++ b/test/gates/serde_boundary_test.rb @@ -5,18 +5,22 @@ require_relative "../../tools/serde_boundary" # SSE-37's mechanism, and the phase-7 segmentation design's spec-forced boundary 5: core's SSE -# layer -- and, once 7c lands, its pagination layer -- holds no serialization dependency, checked -# by a PARSED scan of every require, every constant read and every RBS type name under the -# guarded paths. The gate that would have caught a `require "json"` or a `Dexpace::Serde` -# reference the require allowlist cannot see, because the seam is core's own. +# layer and its pagination layer hold no serialization dependency, checked by a PARSED scan of +# every require, every constant read and every RBS type name under the guarded paths. The gate +# that would have caught a `require "json"` or a `Dexpace::Serde` reference the require allowlist +# cannot see, because the seam is core's own. The pagination rows were PENDING while 7b and 7c +# were built on one base apart, and GUARDED since the reconcile pass of 2026-09-20. class SerdeBoundaryTest < GateCase FIXTURES = File.join(ROOT, "test/fixtures/gates/serde_boundary") - test "the real repository is clean, and its pending rows are the pagination layer's" do + test "the real repository is clean, both layers guarded and nothing pending" do assert_empty(SerdeBoundary.violations(ROOT)) - assert_equal(%w[gems/dexpace-core/lib/dexpace/page/**/*.rb + assert_empty(SerdeBoundary.pending(ROOT)) + assert_equal(%w[gems/dexpace-core/lib/dexpace/page.rb + gems/dexpace-core/lib/dexpace/page/**/*.rb + gems/dexpace-core/sig/dexpace/page.rbs gems/dexpace-core/sig/dexpace/page/**/*.rbs], - SerdeBoundary.pending(ROOT).map(&:first),) + SerdeBoundary::GUARDED.map(&:first).grep(%r{/page}),) end test "every GUARDED glob matches at least one real file, so the boundary checks something" do @@ -71,6 +75,16 @@ class SerdeBoundaryTest < GateCase assert_empty(scan("clean.rbs")) end + test "a pagination violation names spec-forced boundary 5, not SSE-37" do + page_rows = SerdeBoundary::GUARDED.select { |glob, _| glob.include?("/page") } + + assert_equal(["spec-forced boundary 5"] * 4, page_rows.map(&:last)) + found = SerdeBoundary.scan_file(File.join(FIXTURES, "files", "requires_json.rb"), + requirement: page_rows.first.last,) + + assert_includes(found.join("\n"), "(spec-forced boundary 5)") + end + test "rejects a serde type name in a guarded signature" do assert_includes(scan("names_serde.rbs"), "names the type Serde") assert_includes(scan("names_json.rbs"), "names the type JSON") @@ -91,6 +105,8 @@ class SerdeBoundaryTest < GateCase end end + # The fixture holds a file for every other guarded glob, the pagination ones included, so the + # one violation is the one empty glob's and nothing else's. test "a GUARDED glob matching no file is a violation naming the glob, never a silent pass" do found = SerdeBoundary.violations(File.join(FIXTURES, "empty_glob")) @@ -106,12 +122,13 @@ class SerdeBoundaryTest < GateCase refute_predicate(status, :success?) assert_includes(err, "lib/dexpace/sse/reader.rb:4: require \"json\"") assert_includes(err, "SSE-37") + refute_includes(err, "matches no file") out, err, status = rake("gates:serde_boundary") assert_predicate(status, :success?, err) - assert_includes(out, "4 guarded globs clean") - assert_includes(out, "PENDING gems/dexpace-core/lib/dexpace/page/**/*.rb") + assert_includes(out, "8 guarded globs clean") + refute_includes(out, "PENDING") end private diff --git a/tools/serde_boundary.rb b/tools/serde_boundary.rb index aa18ee1..d763bb9 100644 --- a/tools/serde_boundary.rb +++ b/tools/serde_boundary.rb @@ -25,28 +25,32 @@ # file, because a glob with a typo scans nothing and reports clean forever, which is precisely the # way a boundary gate stops being a gate. PENDING holds a path whose files do not exist on this # base yet; it is not silent -- the task prints every pending row and its reason on every run -- -# and the row moves to GUARDED in the change that lands the files (7c's pagination layer, in the -# reconcile pass after both lanes are on main). +# and a row moves to GUARDED in the change that lands the files. The pagination layer's rows made +# that move on 2026-09-20, in the reconcile pass that put 7c's tree on the same base as 7b's gate, +# and PENDING has been empty since; it stays as the mechanism for the next layer built beside a +# gate that predates it. module SerdeBoundary extend self - # A guarded glob, relative to the workspace root, and the requirement that guards it. + # A guarded glob, relative to the workspace root, and the requirement that guards it. The + # pagination rows carry spec-forced boundary 5 (phase-7 segmentation design): §12's pagination + # serde-agnosticism carries no requirement ID, so the SSE-37 mechanism is extended one path + # wider, and the entry file has a row of its own for the same reason sse.rb has one -- `**` + # under a directory cannot match the file beside it. GUARDED = [ ["gems/dexpace-core/lib/dexpace/sse.rb", "SSE-37"], ["gems/dexpace-core/lib/dexpace/sse/**/*.rb", "SSE-37"], ["gems/dexpace-core/sig/dexpace/sse.rbs", "SSE-37"], ["gems/dexpace-core/sig/dexpace/sse/**/*.rbs", "SSE-37"], + ["gems/dexpace-core/lib/dexpace/page.rb", "spec-forced boundary 5"], + ["gems/dexpace-core/lib/dexpace/page/**/*.rb", "spec-forced boundary 5"], + ["gems/dexpace-core/sig/dexpace/page.rbs", "spec-forced boundary 5"], + ["gems/dexpace-core/sig/dexpace/page/**/*.rbs", "spec-forced boundary 5"], ].freeze - # Spec-forced boundary 5 (phase-7 segmentation design): §12's pagination serde-agnosticism - # carries no requirement ID, so the SSE-37 mechanism is extended one path wider. Moves to - # GUARDED when lib/dexpace/page/ exists on the same base. - PENDING = [ - ["gems/dexpace-core/lib/dexpace/page/**/*.rb", - "phase-7 segmentation design, spec-forced boundary 5: guarded once 7c's files land",], - ["gems/dexpace-core/sig/dexpace/page/**/*.rbs", - "phase-7 segmentation design, spec-forced boundary 5: guarded once 7c's files land",], - ].freeze + # A path whose files do not exist on this base yet, with the reason it is pending: printed on + # every run, never silent. Empty since the pagination rows moved to GUARDED on 2026-09-20. + PENDING = [].freeze # The constant names a guarded file may not read, wherever they sit in a path: `Serde`, # `Dexpace::Serde`, `Dexpace::Serde::JSON`, `JSON` and `::JSON` -- the last being the spelling