feat(client): page get_timeline, and page from the recent end - #32
Merged
Conversation
…#33) Rebased as a delta on top of #33, which already landed get_timeline's limit/offset/newest_first and the Timeline has_more flags: keep Episode.occurred_at (the column the timeline orders by, previously discarded by the model), the CHANGELOG entry, the README example and the fuller test module, aligned to the wire convention main uses (newest_first as "true"/"false"). Also stop tracking the committed __pycache__ bytecode, which .gitignore already excludes.
Owner
Author
|
Rebased as a delta over #33 (which had landed the get_timeline params + has_more flags meanwhile): this PR now carries what #33 didn't — |
smaramwbc
force-pushed
the
feat/timeline-newest-first
branch
from
August 30, 2026 11:00
9b291d0 to
07e3c11
Compare
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.
The problem
get_timelinesendssubject_idand nothing else, on both clients:/v1/timelineorders ascending and caps each collection, so a caller past that cap gets the subject's oldest records — the opposite of what a consumer keeping a bounded "recent activity" window needs, and the gap widens the longer the subject lives. There was no parameter to change it.Same class of mismatch as #174, which this repo already has a test file for: the REST contract could express something the SDK signature could not.
This is the Python half. Server: statewave#362 (
limit/offset+ has-more flags) and statewave#363 (newest_first). TypeScript: statewave-ts#31.The change
get_timelineacceptslimit,offset,newest_firston the sync and async clients. Keyword-only, defaulting toNone, and a parameter is sent only when supplied — so a bareget_timeline(subject_id)puts the same bytes on the wire as before.Timelinegainsepisodes_has_more/memories_has_moreasbool | None.Nonemeans the server did not report it, which is not the same asFalse; a missing flag must not read as "this page is complete".Episodegainsoccurred_at— the event's own time, as againstcreated_at, its ingest time.Why the model changes are load-bearing rather than cosmetic
No model in
statewave/models.pysetsmodel_config, so pydantic v2's defaultextra="ignore"applies. Undeclared server fields are destroyed on parse — not merely untyped:So declaring these fields is the feature; without it a caller cannot reach them by any route. The same mechanism is why
Episode.occurred_atwas being dropped on everyget_timeline()call already. It belongs in this PR specifically because it is the column the timeline orders by — asking for the most recent episodes and then being unable to see what made them recent is half an answer.bool | None = Nonefollows the house idiom for "an older server does not send this" (Receipt.region,Receipt.receipt_signature,Receipt.policy_snapshot).Two judgement calls worth your ruling
Bool convention.
search_memorieshand-stringifies (if semantic: params["semantic"] = "true") and omits the key when false. I pass a real bool instead, and let httpx render it — pinned by a test that drives a realMockTransportand asserts the literal query stringsubject_id=subj-1&limit=20&newest_first=true. The reason to diverge:newest_firstis a direction where "the caller did not say" differs meaningfully from "the caller said oldest", and a three-stateNonedefault letslimit,offsetandnewest_firstshare one convention in a single signature. Happy to switch to thesearch_memoriesstyle if you would rather have one convention in the file.session_idleft alone.Episodealso dropssession_id, which the server sends, by the sameextra="ignore"mechanism. It has nothing to do with ordering, so I left it out to keep the PR's story to one thing. Worth a follow-up.Backward compatibility
Every parameter after
subject_idwas already keyword-only on both clients, so there is no positional slot to capture and no existing call changes shape. Unlike the TypeScript SDK, which needed overloads and a runtime check to protectgetTimeline(id, {signal}), this is purely additive.Tests
tests/test_timeline.pyis new — the endpoint had no test at all. Thirteen cases:subject_id; all three params forwarded; explicitnewest_first=Falsesurvives; bool reaches the wire as lowercasetrue; a subject id containing&/=round-tripsNonewhen the server omits them;occurred_atsurfaced; an episode withoutoccurred_atstill parsesget_timelinesignatures agree — name, kind and defaultEight of the thirteen fail against the unmodified SDK (verified by reverting
statewave/); the rest are regression and compatibility guards.The parity test is there because the two clients are hand-written copies in one 1555-line file with nothing generated, and no existing test asserts their surfaces match — a kwarg added to one and forgotten on the other is the obvious way they drift.
Verification
pytest tests/ -q— 105 passed (92 existing + 13 new).ruff check statewave/ tests/— clean, on thelint.selectpin from 0dc8784.origin/main(0dc8784), so it includes your ruff fix.No version bump and no
uv.lockregeneration, per PUBLISHING.md's split between feature and release commits; the CHANGELOG entry goes under## Unreleased.Note on release
These parameters need a server that accepts them; against an older instance FastAPI ignores unknown query parameters and returns its default page. That is exactly why the has-more flags are optional — the SDK cannot tell a server that said "no more" from one that said nothing. Worth holding the release until the server PRs land, so
newest_firstnever ships as a parameter that silently does nothing.🤖 Generated with Claude Code