Skip to content

Phase 7c: pagination — strategies, views and both engines - #84

Merged
Wahbeh-Mohammad merged 4 commits into
mainfrom
28-phase-7c-pagination
Sep 20, 2026
Merged

Wahbeh-Mohammad merged 4 commits into
mainfrom
28-phase-7c-pagination

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Part of #28. First PR of phase 7c's three-PR stack — the code — built off main at c53638b (phases 0–6) concurrently with 7a #26, 7b #27 and 8a #30, reviewed and approved there, then rebased and re-proven onto main at 34f52e8 (phase 7b, #81–#83) by a reconcile pass before this push. Its tests are the next PR up and the phase record the one after.

What lands

The pagination layer under Dexpace::Page and Dexpace::URL.resolve — 45 files, +2,636 / −22 on top of 7b's main — PAGE-1–PAGE-36, all thirty-six ✅ (PAGE-36's adapter half is 8a's conformance test, cited).

  • Dexpace::URL.resolve(base, reference) in phase 1's http/url.rb — URI::RFC3986_PARSER.join (never URI.join, which is cop-banned), frozen through the private own, nil on URI::InvalidURIError; a String or URI base, a String reference. It does not screen dispatchability — that is Page.next_request_from's (P7-104). PAGE-19's query-only reference keeps the base's full path (the RFC 2396 trap), proven under uri 0.12.5 and 1.1.1.
  • Dexpace::Page — a plain class including Closeable (not a Data); .build(response:, items:, next_link: nil, continuation_token: nil) refuses anything but a real Response and an Array and validates the two keys as nil or a frozen String copy (round 0's R0-1); Page.next_request_from(template, response, target) answers nil — PAGE-19's end-of-stream — for a blank target, one that begins with # (round 1's R1-2, P7-117), one join cannot resolve, and one this client cannot dispatch (no http/https scheme or no host: mailto:, javascript:, ftp:, http:foo, http:///p; 6b's dispatchable? screen copied, because Redirect::Location is a private_constant — P7-104, consistent with REDIR-18); readers survive close.
  • Info (frozen Data(items, next_request, next_link, continuation_token) with .build and .terminal(items:); next_request == nil is the one end-of-stream signal), QueryRewriter (get/set/rewrite_url over raw &-split segments with RFC 3986 coding, never www-form; an empty splice answers nil, P7-4; a 512-sample property test keeps every untargeted segment byte-identical), LinkHeader (a private_constant character-level scanner, no Regexp: quoted commas and semicolons, quoted-pair escapes, any-case multi-token rel, the first rel parameter only per RFC 8288 §3.3 — round 0's R0-3, P7-116 — and malformed input as end-of-stream, P7-115).
  • The three strategies, each a frozen Data on the phase-1 pattern with its answer shapes checked, never destructured blindly (P7-107): CursorStrategy(extract, parameter = "cursor") — one extractor call, [items, cursor] shape, nil/"" terminal, the cursor also travelling as continuation_token, a raising extractor a parse failure and never an end; PageNumberStrategy(extract_items, parameter = "page", start = 1) — the current page read from the executed request through a private anchored [0-9]+ with its own timeout: 1.0 (P7-114); LinkStrategy(extract_items, header = "Link") — several Link instances folded with ", " (PAGE-20).
  • Walk (private) — the lifetime owner, generic over a per-walk drive (P7-111): the cap checked before the exchange; the new page written into its slot before the previous is closed (the plan's order would have stranded the page being advanced to); a second staged page refused with PageStateError; #release clears both slots before anything can raise, first failure primary, second suppressed. Closing (private, extend self): close_walk / parse_or_close — a non-Info answer refused by name, the fatal family closed quietly and propagated unchanged (P7-110).
  • Items (an Enumerable over a fresh walk per #each; the page closed before its first item is yielded, PAGE-11) and Pages (the single-use view — a second #each, or obtaining a block-less enumerator twice, raises Dexpace::Page::PageStateError, < ::StandardError with include Dexpace::Error, in page/page_state_error.rb — never InvalidArgumentError; the same shape as 7b's SSE::StreamStateError, closing the argument-family half of phase 10's inbound bullet on the two single-use latches; the shared supertype stays on the list, P7-103). Both record the consumer's primary in a rescue ::Exception => error arm and hand the local to Closing.close_walk in ensure — never $!, which is the caller's inside anything called from a caller's rescue (P7-105).
  • Paginator — frozen Data(transport, template, strategy, cap = Float::INFINITY, options = RequestOptions::EMPTY), cap a strictly positive Numeric validated in #initialize (PAGE-9); #items / #pages, #each_item / #each_page requiring a block; no cancellation: keyword in v1 — the transport receives Cancellation.none, never nil, which dies inside Pipeline.standard's retry step (P7-102; a keyword is a pure widening for phase 8 or 10). AsyncPaginator — #walk / #walk_pages(consumer, cancellation: nil) over a private re-arm trampoline (caller.size flat across 3,000 pages on both paths; the recursive shape overflows at ~2,600 frames through the real Completer, not the plan's ~11,000); with an executor the first dispatch is posted too (P7-112); the future settles with the page count and a transport-side cancellation is forwarded as a cancellation carrying its reason (P7-109); PAGE-33's two halves at their two code sites, one of them phase 2's Completer#fulfil. Fetchers — Data(first, next_page, options) reusing Walk; fetcher arity read through Registry.callable? decides whether options are passed by identity (PAGE-35); link keyed before token (PAGE-34).
  • sig/dexpace/page.rbs carries _Strategy, _Extractor and _Executor (def post: () { () -> void } -> void) nested in class Page — the plan's sig-only strategy.rbs fails the gem_layout gate (P7-106); 8b's plan is told so in one sentence at the point that names the file (the phase-8 lane implements it). Every private_constant has its .rbs mirror. lib/dexpace.rb gains a fifteen-line # Phase 7c: block; the smoke suite's LAYERS table gains PAGE; the manifest regenerated once, +77 rows (1,137 → 1,214 on the old base; 1,180 → 1,257 on top of 7b), every row read against the object model.
  • No gate of its own for spec-forced boundary 5: 7b's gates:serde_boundary owns it (P7-108); 7c's own guard is page_test.rb's token scan — no Serde or JSON anywhere under lib/dexpace/page/, comments included, over exactly fifteen files. The reconcile pass flipped the gate's page/** rows from PENDING to GUARDED now that both lanes share a tree (3a1f0a4, one chore commit on this branch): four guarded rows — page.rb, page/**/*.rb and their two sig/ mirrors, on the sse.rb precedent, since ** under a directory cannot match the file beside it — PENDING now empty, test/gates/serde_boundary_test.rb and eight fixture files updated, the guard proven to bite by hand (a Serde comment under page/ turns the gate red).

Decisions taken in the open, against the plan's text

Ledger rows P7-101–P7-117 in the design's As-built addendum and the checklist's "Deviations from the plan". Beyond those above: the plan's five doubles collapse to two new top-level ones (PageFixtures, ProbeExecutor) beside 6a's ScriptedTransport / ScriptedAsyncTransport, 3b's FakeResponseBody, 4b's RecordingBody and phase 2's InlineExecutor; no Response double anywhere (7a is writing one concurrently) — every response is a real Response; items are shallow copies (dup.freeze, P7-101), never Model.own's deep copy, which raises on a Proc; RequestOptions.build takes all three keywords; the cap's default is Float::INFINITY with the production advice in the YARD and on the page rather than a configuration key.

Reconciliation onto 7b's main

The rebase met two code conflicts, both resolved on this branch inside the commits that caused them: lib/dexpace.rb keeps 7b's block then 7c's; dexpace_test.rb keeps both layer pins and merges the two same-named PhaseSevenLayers nested classes into one carrying both lanes' cases. The core manifest auto-merged to exactly the regenerated 1,257 rows (surface:regenerate changed nothing). Every file only 7c touched is byte-identical to the reviewed tip; every file only main touched is byte-identical to main except the two the gate flip mandates.

Layering

Each tip is green under every gate on its own tree — eighteen gates now, 7b's included. This branch is green on the SimpleCov floor too — 95.05% on 4.0.6 (0 failures); the tests PR takes the same tree to 99.98% with 3,146 runs / 70,084 assertions / 0 skips (the one uncovered core line is phase 2's registry.rb:253, as on main).

Verification

  • Independent review, three rounds by three fresh reviewers with a fix round between each: round 0 0 blocking / 2 should-fix / 5 nits; round 1 0 / 1 / 1; round 2 approve, 0 / 0 / 1 nit. Code changes from review: Page.build validating the two keys (a claim the checklist made and the code did not keep), LinkHeader reading the first rel only, the fragment-only target as end-of-stream; the round-1 should-fix was a test gap (Walk#release's slots-cleared-before-raise invariant unpinned — a mutation moving the two clears past the raise survived every suite).
  • All gates individually at this tip on 4.0.6 (seventeen on the old base, eighteen after the rebase); the matrix set on 3.2.11 (and gates:clean_bundle there loads all six gems under the default uri 0.12.5); honest RuboCop clean; probe clean at the docs tip — each run twice, by the reviewers on the old base and by the reconcile pass and the manager's pre-push checks on the rebased tips.
  • Mutations: 52 (52 caught), 73 (70 caught), 85 (83 caught) by the three reviewers on 4.0.6 and 3.2.11; the survivors are recorded equivalents (a strip join makes unobservable; a $! shape the fixtures cannot reach).
  • By experiment, both interpreters: the trampoline's depth at page 1 versus page 3,000 (inline and on an executor); #with on the floor routing through .build; the twelve rel=next target forms through a real Pipeline.standard; a consumer raise versus break under a raising close (primary with the suppressed IOError, versus the IOError propagating); Fiber[] inheritance across an external enumerator's pulls; an executor spawning a thread per post and a transport settling from a fresh thread per page (200 pages, every settler joined); cancelling the walk while an exchange is in flight, then settling it (the response closed by Completer#fulfil, PAGE-33); the 100,000-digit page number under the timed Regexp.

Known follow-ups from the final review (not blocking a gate)

  • R2-1 (nit, code) — page.rb:53: a transport answering a non-Response that a strategy's #parse tolerates is refused by name at Page.build after the parse, and the duck is never closed (only the parse-failure path closes it). Optional hardening: check is_a?(Response) before Closing.parse_or_close in the two drives and close_quietly it on refusal.
  • R0-6 (nit, deferred) — the implementer's docs commit body says "thirteen cross-reference rows"; the checklist has twelve. Never amended.
  • R0-7 (deferred) — the eight-line correction to 8b's plan (Task 9: _Executor's real file, and that the executor mode posts the first dispatch too) rides the docs branch, where CLAUDE.md's routing rule sends work that falls inside another phase's plan.
  • Routed: the shared single-use supertype across Page::PageStateError and SSE::StreamStateError (phase 10's inbound list, the open half of its 2026-09-13 bullet); PAGE-15's wrapping clause vacuous by a false antecedent (already docs/first-release.md's C13, cited); four corpus findings as reference entries in docs/knowledge/notes/pagination.md, including that $! is the caller's error inside a callee — R8's prescription is wrong.

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.
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
<u>; 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.
…ne 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.
… 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.
@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* labels Sep 20, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 4c74410 into main Sep 20, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 28-phase-7c-pagination branch September 20, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant