Repository navigation
Phase 7c: pagination — strategies, views and both engines - #84
Merged
Merged
Conversation
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.
This was referenced Sep 20, 2026
This was referenced Sep 20, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #28. First PR of phase 7c's three-PR stack — the code — built off
mainatc53638b(phases 0–6) concurrently with 7a #26, 7b #27 and 8a #30, reviewed and approved there, then rebased and re-proven ontomainat34f52e8(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::PageandDexpace::URL.resolve— 45 files, +2,636 / −22 on top of 7b'smain—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'shttp/url.rb—URI::RFC3986_PARSER.join(neverURI.join, which is cop-banned), frozen through the privateown,nilonURI::InvalidURIError; a String or URI base, a String reference. It does not screen dispatchability — that isPage.next_request_from's (P7-104).PAGE-19's query-only reference keeps the base's full path (the RFC 2396 trap), proven underuri0.12.5 and 1.1.1.Dexpace::Page— a plain class includingCloseable(not aData);.build(response:, items:, next_link: nil, continuation_token: nil)refuses anything but a realResponseand an Array and validates the two keys asnilor a frozen String copy (round 0's R0-1);Page.next_request_from(template, response, target)answersnil—PAGE-19's end-of-stream — for a blank target, one that begins with#(round 1's R1-2, P7-117), onejoincannot resolve, and one this client cannot dispatch (no http/https scheme or no host:mailto:,javascript:,ftp:,http:foo,http:///p; 6b'sdispatchable?screen copied, becauseRedirect::Locationis aprivate_constant— P7-104, consistent withREDIR-18); readers survive close.Info(frozenData(items, next_request, next_link, continuation_token)with.buildand.terminal(items:);next_request == nilis the one end-of-stream signal),QueryRewriter(get/set/rewrite_urlover raw&-split segments with RFC 3986 coding, never www-form; an empty splice answersnil, P7-4; a 512-sample property test keeps every untargeted segment byte-identical),LinkHeader(aprivate_constantcharacter-level scanner, no Regexp: quoted commas and semicolons, quoted-pair escapes, any-case multi-tokenrel, the firstrelparameter only per RFC 8288 §3.3 — round 0's R0-3, P7-116 — and malformed input as end-of-stream, P7-115).Dataon 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 ascontinuation_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 owntimeout: 1.0(P7-114);LinkStrategy(extract_items, header = "Link")— severalLinkinstances 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 withPageStateError;#releaseclears both slots before anything can raise, first failure primary, second suppressed.Closing(private,extend self):close_walk/parse_or_close— a non-Infoanswer refused by name, the fatal family closed quietly and propagated unchanged (P7-110).Items(anEnumerableover a fresh walk per#each; the page closed before its first item is yielded,PAGE-11) andPages(the single-use view — a second#each, or obtaining a block-less enumerator twice, raisesDexpace::Page::PageStateError,< ::StandardErrorwithinclude Dexpace::Error, inpage/page_state_error.rb— neverInvalidArgumentError; the same shape as 7b'sSSE::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 arescue ::Exception => errorarm and hand the local toClosing.close_walkinensure— never$!, which is the caller's inside anything called from a caller's rescue (P7-105).Paginator— frozenData(transport, template, strategy, cap = Float::INFINITY, options = RequestOptions::EMPTY),capa strictly positive Numeric validated in#initialize(PAGE-9);#items/#pages,#each_item/#each_pagerequiring a block; nocancellation:keyword in v1 — the transport receivesCancellation.none, nevernil, which dies insidePipeline.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.sizeflat across 3,000 pages on both paths; the recursive shape overflows at ~2,600 frames through the realCompleter, 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'sCompleter#fulfil.Fetchers—Data(first, next_page, options)reusingWalk; fetcher arity read throughRegistry.callable?decides whether options are passed by identity (PAGE-35); link keyed before token (PAGE-34).sig/dexpace/page.rbscarries_Strategy,_Extractorand_Executor(def post: () { () -> void } -> void) nested inclass Page— the plan's sig-onlystrategy.rbsfails 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). Everyprivate_constanthas its.rbsmirror.lib/dexpace.rbgains a fifteen-line# Phase 7c:block; the smoke suite's LAYERS table gainsPAGE; 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.gates:serde_boundaryowns it (P7-108); 7c's own guard ispage_test.rb's token scan — noSerdeorJSONanywhere underlib/dexpace/page/, comments included, over exactly fifteen files. The reconcile pass flipped the gate'spage/**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/**/*.rband their twosig/mirrors, on thesse.rbprecedent, since**under a directory cannot match the file beside it — PENDING now empty,test/gates/serde_boundary_test.rband eight fixture files updated, the guard proven to bite by hand (aSerdecomment underpage/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'sScriptedTransport/ScriptedAsyncTransport, 3b'sFakeResponseBody, 4b'sRecordingBodyand phase 2'sInlineExecutor; no Response double anywhere (7a is writing one concurrently) — every response is a realResponse; items are shallow copies (dup.freeze, P7-101), neverModel.own's deep copy, which raises on a Proc;RequestOptions.buildtakes all three keywords; the cap's default isFloat::INFINITYwith the production advice in the YARD and on the page rather than a configuration key.Reconciliation onto 7b's
mainThe rebase met two code conflicts, both resolved on this branch inside the commits that caused them:
lib/dexpace.rbkeeps 7b's block then 7c's;dexpace_test.rbkeeps both layer pins and merges the two same-namedPhaseSevenLayersnested classes into one carrying both lanes' cases. The core manifest auto-merged to exactly the regenerated 1,257 rows (surface:regeneratechanged nothing). Every file only 7c touched is byte-identical to the reviewed tip; every file onlymaintouched is byte-identical tomainexcept 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 onmain).Verification
Page.buildvalidating the two keys (a claim the checklist made and the code did not keep),LinkHeaderreading the firstrelonly, 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).gates:clean_bundlethere loads all six gems under the defaulturi0.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.stripjoinmakes unobservable; a$!shape the fixtures cannot reach).#withon the floor routing through.build; the twelverel=nexttarget forms through a realPipeline.standard; a consumerraiseversusbreakunder a raising close (primary with the suppressedIOError, versus theIOErrorpropagating);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 byCompleter#fulfil,PAGE-33); the 100,000-digit page number under the timed Regexp.Known follow-ups from the final review (not blocking a gate)
page.rb:53: a transport answering a non-Responsethat a strategy's#parsetolerates is refused by name atPage.buildafter the parse, and the duck is never closed (only the parse-failure path closes it). Optional hardening: checkis_a?(Response)beforeClosing.parse_or_closein the two drives andclose_quietlyit on refusal._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.Page::PageStateErrorandSSE::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 (alreadydocs/first-release.md's C13, cited); four corpus findings as reference entries indocs/knowledge/notes/pagination.md, including that$!is the caller's error inside a callee — R8's prescription is wrong.