From d320c01a520f9c9a0176b0683afc7ceaa2fcbc09 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 11:40:13 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20pagination=20=E2=80=94=20the=20page?= =?UTF-8?q?=20value,=20strategies,=20views=20and=20both=20engines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7c, chapter 12 (PAGE-1..PAGE-36), in dexpace-core under Dexpace::Page, a class that is also the namespace. The page value owns exactly one live response behind Closeable's latch, with .build and the public resolution branch .next_request_from (a blank, unresolvable or non-dispatchable target is end-of-stream). Info carries a strategy's answer, its nil next_request the one end-of-stream signal. QueryRewriter splices the raw query byte-for-byte over phase 1's PercentEncoding and never Query. The three built-in strategies take a caller-supplied extractor and never a codec; LinkHeader is a character- level RFC 8288 state machine with no Regexp. The private Walk owns every live page (the cap, the exhaustion latch, the current and look-ahead slots) over a per-walk drive, and Closing holds the two close disciplines both views share, built without $! because it is the caller's inside a caller's rescue. Items eager-closes each page before yielding; Pages is single-use (PageStateError, a state error) with a one-slot look-ahead. Paginator and Fetchers are frozen Data engines; AsyncPaginator is a re-arm trampoline over Future#on_settle whose every callback is total, with an optional #post executor that runs the first dispatch too. URL.resolve is added beside .parse! for PAGE-19's RFC 3986 resolution. The smoke suite pins the layer and the surface manifest gains 77 rows. --- gems/dexpace-core/lib/dexpace.rb | 23 ++ gems/dexpace-core/lib/dexpace/http/url.rb | 37 ++ gems/dexpace-core/lib/dexpace/page.rb | 136 +++++++ .../lib/dexpace/page/async_paginator.rb | 372 ++++++++++++++++++ gems/dexpace-core/lib/dexpace/page/closing.rb | 86 ++++ .../lib/dexpace/page/cursor_strategy.rb | 96 +++++ .../dexpace-core/lib/dexpace/page/fetchers.rb | 174 ++++++++ gems/dexpace-core/lib/dexpace/page/info.rb | 77 ++++ gems/dexpace-core/lib/dexpace/page/items.rb | 82 ++++ .../lib/dexpace/page/link_header.rb | 149 +++++++ .../lib/dexpace/page/link_strategy.rb | 68 ++++ .../lib/dexpace/page/page_number_strategy.rb | 85 ++++ .../lib/dexpace/page/page_state_error.rb | 21 + gems/dexpace-core/lib/dexpace/page/pages.rb | 123 ++++++ .../lib/dexpace/page/paginator.rb | 169 ++++++++ .../lib/dexpace/page/query_rewriter.rb | 124 ++++++ gems/dexpace-core/lib/dexpace/page/walk.rb | 141 +++++++ gems/dexpace-core/sig/dexpace/http/url.rbs | 1 + gems/dexpace-core/sig/dexpace/page.rbs | 54 +++ .../sig/dexpace/page/async_paginator.rbs | 66 ++++ .../dexpace-core/sig/dexpace/page/closing.rbs | 12 + .../sig/dexpace/page/cursor_strategy.rbs | 22 ++ .../sig/dexpace/page/fetchers.rbs | 45 +++ gems/dexpace-core/sig/dexpace/page/info.rbs | 23 ++ gems/dexpace-core/sig/dexpace/page/items.rbs | 16 + .../sig/dexpace/page/link_header.rbs | 26 ++ .../sig/dexpace/page/link_strategy.rbs | 18 + .../sig/dexpace/page/page_number_strategy.rbs | 24 ++ .../sig/dexpace/page/page_state_error.rbs | 8 + gems/dexpace-core/sig/dexpace/page/pages.rbs | 24 ++ .../sig/dexpace/page/paginator.rbs | 41 ++ .../sig/dexpace/page/query_rewriter.rbs | 18 + gems/dexpace-core/sig/dexpace/page/walk.rbs | 32 ++ gems/dexpace-core/test/dexpace_test.rb | 36 +- test/fixtures/surface/dexpace-core.txt | 77 ++++ 35 files changed, 2505 insertions(+), 1 deletion(-) create mode 100644 gems/dexpace-core/lib/dexpace/page.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/async_paginator.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/closing.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/cursor_strategy.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/fetchers.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/info.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/items.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/link_header.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/link_strategy.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/page_number_strategy.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/page_state_error.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/pages.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/paginator.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/query_rewriter.rb create mode 100644 gems/dexpace-core/lib/dexpace/page/walk.rb create mode 100644 gems/dexpace-core/sig/dexpace/page.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/async_paginator.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/closing.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/cursor_strategy.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/fetchers.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/info.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/items.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/link_header.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/link_strategy.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/page_number_strategy.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/page_state_error.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/pages.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/paginator.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/query_rewriter.rbs create mode 100644 gems/dexpace-core/sig/dexpace/page/walk.rbs 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..a728df4 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page.rb @@ -0,0 +1,136 @@ +# 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) and 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). + # + # @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 or a non-Array items + 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, blank or whitespace-only target is end-of-stream + # BEFORE resolution is attempted, because `URI::RFC3986_PARSER.join(base, "")` succeeds and + # answers the base itself, so `<>; rel=next` would otherwise loop until the page cap (P7-5); + # a target that cannot resolve at all is end-of-stream, never an error (PAGE-19's own text); + # and 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. + # + # @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? || target.strip.empty? + + 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 + + # 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 = next_link + @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 + + # 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..a6175d7 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/page/link_header.rb @@ -0,0 +1,149 @@ +# 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). + # + # 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| + return target if params.any? { |name, value| name == "rel" && next_token?(value) } + 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..dbd08db --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/page.rbs @@ -0,0 +1,54 @@ +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.dispatchable?: (URI::Generic target) -> bool + 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/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 From 39c937322eeb32b1e0d3b8382948e1de4327ca2f Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 13:36:43 +0300 Subject: [PATCH 2/4] fix: Page.build names a non-String key; LinkHeader reads one rel Phase 7c, review round 0 (R0-1, R0-3). Page#initialize now holds next_link and continuation_token to the rule Info already applied to the same two members -- nil, or a String copied frozen, anything else a named Dexpace::InvalidArgumentError -- so Page.build(next_link: 42) is refused at construction instead of surfacing one page later as a NoMethodError in Fetchers::Drive#key_of, and the checklist's "Info and Page validate next_link and continuation_token" is true as written (P7-107). LinkHeader#next_target reads only the FIRST rel parameter of each link-value: RFC 8288 section 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 no longer a next one (P7-116). The sig mirror gains the private helper's line; no public signature and no manifest row changes. --- gems/dexpace-core/lib/dexpace/page.rb | 23 +++++++++++++++---- .../lib/dexpace/page/link_header.rb | 8 +++++-- gems/dexpace-core/sig/dexpace/page.rbs | 1 + 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/page.rb b/gems/dexpace-core/lib/dexpace/page.rb index a728df4..c7aeb31 100644 --- a/gems/dexpace-core/lib/dexpace/page.rb +++ b/gems/dexpace-core/lib/dexpace/page.rb @@ -35,16 +35,20 @@ class Page private_class_method :new # The validating factory every page goes through: a real Dexpace::Response (checked the way - # Response#initialize checks its request) and an Array of items, copied shallowly and frozen + # 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). + # 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 or a non-Array items + # @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}" @@ -102,8 +106,8 @@ def self.dispatchable?(target) def initialize(response, items, next_link, continuation_token) @response = response @items = items.dup.freeze - @next_link = next_link - @continuation_token = continuation_token + @next_link = optional_string("next_link", next_link) + @continuation_token = optional_string("continuation_token", continuation_token) initialize_closeable(owned: true) end @@ -127,6 +131,15 @@ 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 diff --git a/gems/dexpace-core/lib/dexpace/page/link_header.rb b/gems/dexpace-core/lib/dexpace/page/link_header.rb index a6175d7..b7675eb 100644 --- a/gems/dexpace-core/lib/dexpace/page/link_header.rb +++ b/gems/dexpace-core/lib/dexpace/page/link_header.rb @@ -19,7 +19,10 @@ class Page # 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). + # 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 @@ -45,7 +48,8 @@ def next_target(values) return nil if values.nil? || values.empty? link_values(values.join(", ")).each do |target, params| - return target if params.any? { |name, value| name == "rel" && next_token?(value) } + rel = params.find { |name, _value| name == "rel" } + return target if !rel.nil? && next_token?(rel[1]) end nil end diff --git a/gems/dexpace-core/sig/dexpace/page.rbs b/gems/dexpace-core/sig/dexpace/page.rbs index dbd08db..e2929fc 100644 --- a/gems/dexpace-core/sig/dexpace/page.rbs +++ b/gems/dexpace-core/sig/dexpace/page.rbs @@ -49,6 +49,7 @@ module Dexpace DISPATCHABLE_SCHEMES: Array[String] def self.dispatchable?: (URI::Generic target) -> bool + def optional_string: (String name, untyped value) -> String? def release: () -> void end end From 647924ae0552249b75560781776f1e24a45f60d8 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 15:40:14 +0300 Subject: [PATCH 3/4] fix: a fragment-only rel=next target is end-of-stream, as the empty one is Phase 7c, review round 1 (R1-2). Page.next_request_from screened the empty and whitespace-only target before resolution (P7-5) but resolved `<#>; rel=next` and `<#frag>; rel=next` to the current page plus a fragment the wire never carries, so a server emitting either was re-fetched until the page cap, which defaults to unbounded. RFC 3986 section 4.4 names the empty reference and the fragment-only one together as the same-document forms whose dereference "should not result in a new retrieval action", so the guard now reads both off the raw target, surrounding whitespace aside, through a private same_document? (P7-117). The screen stays syntactic on purpose: ``, `` (which uri resolves to the base itself) and the current URL spelled out are next requests like any other, 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. The sig mirror gains the private helper's line; no public signature and no manifest row changes. --- gems/dexpace-core/lib/dexpace/page.rb | 40 +++++++++++++++++++------- gems/dexpace-core/sig/dexpace/page.rbs | 1 + 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/page.rb b/gems/dexpace-core/lib/dexpace/page.rb index c7aeb31..8a9a89c 100644 --- a/gems/dexpace-core/lib/dexpace/page.rb +++ b/gems/dexpace-core/lib/dexpace/page.rb @@ -67,23 +67,31 @@ def self.build(response:, items:, next_link: nil, continuation_token: nil) # 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, blank or whitespace-only target is end-of-stream - # BEFORE resolution is attempted, because `URI::RFC3986_PARSER.join(base, "")` succeeds and - # answers the base itself, so `<>; rel=next` would otherwise loop until the page cap (P7-5); - # a target that cannot resolve at all is end-of-stream, never an error (PAGE-19's own text); - # and 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. + # 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? || target.strip.empty? + return nil if target.nil? || same_document?(target) resolved = Dexpace::URL.resolve(response.request.url, target) return nil if resolved.nil? || !dispatchable?(resolved) @@ -95,6 +103,16 @@ def self.next_request_from(template, response, target) 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 diff --git a/gems/dexpace-core/sig/dexpace/page.rbs b/gems/dexpace-core/sig/dexpace/page.rbs index e2929fc..0508a2d 100644 --- a/gems/dexpace-core/sig/dexpace/page.rbs +++ b/gems/dexpace-core/sig/dexpace/page.rbs @@ -48,6 +48,7 @@ module Dexpace 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 From 3a1f0a4a6a6163fec7dd1e430bb3c3c641cb1051 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 17:09:15 +0300 Subject: [PATCH 4/4] chore: guard page/ in gates:serde_boundary now that 7c is on the same tree Phase 7b built the serde-boundary gate with the pagination layer's two globs on its printed PENDING list, because 7c's files did not exist on its base; its own comment says the rows move to GUARDED in the change that lands the files. This is that change: 7c's tree is now rebased onto main, which holds 7b, so lib/dexpace/page/**/*.rb and its sig/ mirror move to GUARDED, each carrying "spec-forced boundary 5" as the requirement the violation names, and page.rb / page.rbs gain rows of their own for the reason sse.rb has one -- a `**` under a directory cannot match the file beside it, and 7c's own token scan in page_test.rb counts the entry file among the fifteen it keeps clean. PENDING is empty and stays as the mechanism. The gate's test asserts nothing pending, the four pagination rows and their label, "8 guarded globs clean" from the task, and that the workspace fixture fails on reader.rb's require alone; both fixture workspaces gain a clean page.rb, page/info.rb and their sig mirrors so the empty_glob fixture still reports exactly one empty glob. Proven to bite by hand: a `Dexpace::Serde` read appended to page/info.rb and a `require "json"` appended to page.rb each ran the gate red, and the guard passes again with both reverted. --- .../gems/dexpace-core/lib/dexpace/page.rb | 7 ++++ .../dexpace-core/lib/dexpace/page/info.rb | 9 +++++ .../gems/dexpace-core/sig/dexpace/page.rbs | 4 +++ .../dexpace-core/sig/dexpace/page/info.rbs | 6 ++++ .../gems/dexpace-core/lib/dexpace/page.rb | 7 ++++ .../dexpace-core/lib/dexpace/page/info.rb | 9 +++++ .../gems/dexpace-core/sig/dexpace/page.rbs | 4 +++ .../dexpace-core/sig/dexpace/page/info.rbs | 6 ++++ test/gates/serde_boundary_test.rb | 35 ++++++++++++++----- tools/serde_boundary.rb | 28 ++++++++------- 10 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page.rb create mode 100644 test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/lib/dexpace/page/info.rb create mode 100644 test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page.rbs create mode 100644 test/fixtures/gates/serde_boundary/empty_glob/gems/dexpace-core/sig/dexpace/page/info.rbs create mode 100644 test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page.rb create mode 100644 test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/lib/dexpace/page/info.rb create mode 100644 test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page.rbs create mode 100644 test/fixtures/gates/serde_boundary/workspace/gems/dexpace-core/sig/dexpace/page/info.rbs 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/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