Skip to content

Finish adopting UTCDateTime, and add created_at to TrackResponse - #82

Merged
jeffcrouse merged 3 commits into
mainfrom
feat/type-the-timestamps
Aug 3, 2026
Merged

Finish adopting UTCDateTime, and add created_at to TrackResponse#82
jeffcrouse merged 3 commits into
mainfrom
feat/type-the-timestamps

Conversation

@jeffcrouse

Copy link
Copy Markdown
Member

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 that UTCDateTime already exists in api/schemas/common.pyAnnotated[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_rfc3339 by hand at 47 sites. The annotation just moves that call into the serialiser, so the bytes are identical. tests/test_timestamp_wire_format.py pins it: naive and aware both emit ...Z, any offset normalises to UTC, None stays None, and the serialization-mode schema still carries format: date-time — the last being exactly what PlainSerializer would otherwise silently degrade to a bare string.

That mattered: naively switching these to datetime would have emitted naive ISO without the Z, which Swift rejects outright and JavaScript reads as local time.

A real bug found on the way

CuratedPromptsResponse.generated_at was utcnow().isoformat(). Verified against the live server:

generated_at = '2026-08-03T11:15:48.143190'   ← no offset

That is the exact defect to_rfc3339's docstring exists to describe, on an endpoint carried by the library tag — so it reaches the generated client. Now a UTCDateTime.

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_at was the third hazard — it defaulted to "", which is not a valid date-time and would have broken a strict decoder at runtime.

created_at on TrackResponse

ADR-0021 records that the dateAdded column was built, seen blank on every row, and removed because TrackResponse carried no such field. It does now, and the generated Swift has createdAt: Foundation.Date?. Restoring the column is Phase 4.

Known asymmetry, stated rather than hidden

favorited_at becomes nullable, because the response is built by model_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 says string. Worth reconciling; not silently.

Verification

  • 8 new wire-format tests pass; ruff clean.
  • Schema regenerated via make openapi and vendored to familiar-apple, where both schemes build and 507 Swift tests pass against the regenerated client.
  • The DB-backed suite needs a local postgres I don't have; CI covers it, and the contract tests are the ones that would catch a wire regression.

🤖 Generated with Claude Code

https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW

jeffcrouse and others added 3 commits August 3, 2026 07:50
…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
jeffcrouse merged commit 59abc49 into main Aug 3, 2026
11 of 13 checks passed
@jeffcrouse
jeffcrouse deleted the feat/type-the-timestamps branch August 3, 2026 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant