Finish adopting UTCDateTime, and add created_at to TrackResponse - #82
Merged
Conversation
…sponse
ADR-0007's follow-up called for typing "the 49 timestamp fields still declared as
plain `string`". The survey found something better: `UTCDateTime` already exists in
`api/schemas/common.py` — `Annotated[datetime, PlainSerializer(to_rfc3339),
WithJsonSchema({format: date-time})]` — built for exactly this and adopted by
three files. This finishes the job for the generated surface.
**Wire-compatible by construction, not by luck.** The producers already called
`to_rfc3339` by hand at 47 sites; the annotation moves that call into the
serialiser. The bytes are identical, which `tests/test_timestamp_wire_format.py`
now pins: naive and aware both emit `...Z`, any offset normalises to UTC, and the
serialization-mode schema still carries `format: date-time` — the last being the
thing `PlainSerializer` would otherwise silently degrade.
Fields typed as date-time went from 8 to 27.
**One real bug found on the way.** `CuratedPromptsResponse.generated_at` was
`utcnow().isoformat()` — naive, no offset. Verified live: `2026-08-03T11:15:48.143190`.
That is precisely what `to_rfc3339`'s docstring warns about: Swift's decoder
rejects it and JavaScript reads it as *local* time. It is on the `library` tag, so
it reaches the generated client.
**Deliberately not migrated.** `library_artists.release_date` is a release date
from a scraped dict, not an instant, and `library_sync.started_at` comes from a
progress dict. Typing either would declare a format their values do not keep — the
hazard that makes this a classification job rather than a mechanical pass.
`TrackResponse.created_at` is new: ADR-0021's `dateAdded` column sorts server-side
already, and the column was built, seen blank on every row, and removed for want
of this field.
**Known asymmetry:** `favorited_at` becomes nullable, because the response is built
by `model_validate(track)` and only then assigned. The server always populates it
in the one endpoint that returns it; the web's TS type still says `string`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
…matting Caught by CI, reproduced locally against a real database. The migration unwrapped every `to_rfc3339(x)` call in the files it touched, and I checked the receiving fields with a pattern matching `_at`, `_date` and `timestamp`. `PendingGroupResponse.earliest_scan` matches none of those, so it kept its `str` annotation while its producer began handing it a `datetime` — `test_pending_tracks_with_data` failed with a pydantic string_type error. Re-checked properly this time: every field whose `to_rfc3339` call was removed, by name, against its annotation. `earliest_scan` was the only one left behind. Also fixes the import ordering ruff flagged in library_discover.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
…ted_at `created_at=mt.created_at if mt.created_at else ""` was fine while the field was a `str`. Now that it is a `UTCDateTime` the fallback is a type error, and mypy said so — the column is non-nullable, so the branch was already unreachable. CI reported this as "Backend Lint", which runs ruff *and* mypy; ruff was clean locally, which is why the first fix missed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
jeffcrouse
added a commit
that referenced
this pull request
Aug 7, 2026
Shipped in familiar-apple #82. 92-98% CPU to 5-9%, and 2,320 of 2,712 main-thread samples inside flushObservers down to 13 of 276. The middle measurement is the one that isolates the split: the list fixes and the split were sampled on comparable screens, so 1,352 -> 13 is the split's own doing rather than the two together. The list fixes cut the cost per pass; the split stopped the passes. Three things the building of it settled, now in the record. Making the forwarders get-only found all fourteen write sites as compile errors — point 3 wrote them to keep callers compiling and they turned out to be the safety net too. Point 5's invariant test asserted one wake per tick and got two, because an unchanged duration was re-published four times a second; the assumption was wrong and what it uncovered was real. And step 4's context-menu work was correctly conditional: 71 samples at 4Hz became zero, so restructuring a component with seven call sites would have been work for nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
jeffcrouse
added a commit
that referenced
this pull request
Aug 7, 2026
…lt (#110) * docs(adr): propose ADR-0041 — the playhead is published separately The Mac app sits at 92-98% CPU while playing. A five-second sample found 2,320 of 2,700 main-thread samples inside flushObservers, which is not four bursts a second: a render pass takes longer than the 250ms until the next one, so the view graph never catches up. One property drives it. FamiliarPlayer.currentTime is @published and the engine's timer fires every 0.25s, and @published emits objectWillChange on every assignment regardless of equality. Ten types observe the player; two of them draw the playhead. The other eight — including the whole library window, the queue pane and the WKWebView host — re-render four times a second while reading none of it. This was found once before and defended against locally rather than fixed. CarPlayBridge:125-133 is the existing record, and it is exact: "the selected tab's label visibly blinks — seen in the car, which is the only place it can be seen." CarPlay added a row diff, casting accepted "a struct comparison", the session writer throttles. Three components absorbed the cost privately and none could fix it, because SwiftUI's observation granularity is the object. The axis is already established one layer down. PlaybackSessionStore's "two files, not one, and the split is the whole design" — the queue changes a few times an hour, the position four times a second — is ADR-0028 point 7. This applies the identical reasoning to publication rather than storage. Point 5 is the one that matters: the rule is enforced by a test asserting a time tick does not fire the player's objectWillChange, not by this document. A convention with no enforcement is a comment. Point 4 states the trap plainly — the forwarders that keep twenty existing assertions compiling are not @published, so a view reading them silently never updates. @observable would subsume all of this and is recorded as a rejected-for-scope alternative rather than ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW * docs(adr): accept ADR-0041 — the split is built and measured Shipped in familiar-apple #82. 92-98% CPU to 5-9%, and 2,320 of 2,712 main-thread samples inside flushObservers down to 13 of 276. The middle measurement is the one that isolates the split: the list fixes and the split were sampled on comparable screens, so 1,352 -> 13 is the split's own doing rather than the two together. The list fixes cut the cost per pass; the split stopped the passes. Three things the building of it settled, now in the record. Making the forwarders get-only found all fourteen write sites as compile errors — point 3 wrote them to keep callers compiling and they turned out to be the safety net too. Point 5's invariant test asserted one wake per tick and got two, because an unchanged duration was re-published four times a second; the assumption was wrong and what it uncovered was real. And step 4's context-menu work was correctly conditional: 71 samples at 4Hz became zero, so restructuring a component with seven call sites would have been work for nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Phase 3. The Swift half is
familiar-apple#57.The follow-up was better than it looked
ADR-0007 asked for typing "the 49 timestamp fields still declared as plain
string". The survey found thatUTCDateTimealready exists inapi/schemas/common.py—Annotated[datetime, PlainSerializer(to_rfc3339), WithJsonSchema({format: date-time})]— purpose-built for this, and adopted by three files. So this isn't "type 49 fields", it's "finish adopting the type that was already built."Fields typed as
date-time: 8 → 27.Wire-compatible by construction, not by luck
The producers already called
to_rfc3339by hand at 47 sites. The annotation just moves that call into the serialiser, so the bytes are identical.tests/test_timestamp_wire_format.pypins it: naive and aware both emit...Z, any offset normalises to UTC,NonestaysNone, and the serialization-mode schema still carriesformat: date-time— the last being exactly whatPlainSerializerwould otherwise silently degrade to a bare string.That mattered: naively switching these to
datetimewould have emitted naive ISO without theZ, which Swift rejects outright and JavaScript reads as local time.A real bug found on the way
CuratedPromptsResponse.generated_atwasutcnow().isoformat(). Verified against the live server:That is the exact defect
to_rfc3339's docstring exists to describe, on an endpoint carried by thelibrarytag — so it reaches the generated client. Now aUTCDateTime.Deliberately not migrated
This is a classification job, not a mechanical pass:
library_artists.release_date— a release date out of a scraped dict, not an instant.library_sync.started_at— comes from a progress dict.Typing either would declare a format their values don't keep.
favorites.favorited_atwas the third hazard — it defaulted to"", which is not a valid date-time and would have broken a strict decoder at runtime.created_atonTrackResponseADR-0021 records that the
dateAddedcolumn was built, seen blank on every row, and removed becauseTrackResponsecarried no such field. It does now, and the generated Swift hascreatedAt: Foundation.Date?. Restoring the column is Phase 4.Known asymmetry, stated rather than hidden
favorited_atbecomes nullable, because the response is built bymodel_validate(track)and only then assigned — a required field would fail validation there. The server always populates it in the one endpoint that returns it, but the web's TS type still saysstring. Worth reconciling; not silently.Verification
ruffclean.make openapiand vendored tofamiliar-apple, where both schemes build and 507 Swift tests pass against the regenerated client.🤖 Generated with Claude Code
https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW