feat!: typed identifiers, AtDatetime and enumerators for app.bsky (2/4 of #119) #155
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!155
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-119-typed-bsky"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of #119 (2/4: app.bsky).
Builds on #154 (part 1) and follows the conventions it wrote down in
CONTRIBUTING.md. This converts every model and client undersrc/ATProtoNet/Lexicon/App/Bsky/**to typed identifiers,AtDatetime,IReadOnlyList<T>andICursorPage<T>, and adds theEnumerate*methods.How to review this
The change is large but mostly mechanical. Suggested order:
tests/ATProtoNet.Tests/Conventions/TypedIdentifierGuardTests.cs:ATProtoNet.Lexicon.App.Bsky.is gone fromPendingConversions, so the reflection guard now enforces this namespace. Eight newExceptionsentries each carry a reason: the genericuri-format link URLs, andsearchPosts'since/until, which the Lexicon leaves format-less because they accept a bare date.tests/ATProtoNet.Tests/Lexicon/App/Bsky/TypedBskyClientTests.cs(new) shows the behaviour end to end: what goes out on the wire, what comes back typed, and how the enumerators walk pages. It also checks by reflection that everyapp.bskytype with aCursorimplementsICursorPage<T>.*Client.csfiles hold the real API decisions: parameter order, enumerator names, and which request types became internal.*Models.csfiles are find-and-replace:string→ the typed identifier,List<T>→IReadOnlyList<T>, plus: ICursorPage<T>with an explicitItems. JSON names are unchanged."bafy1"became real ones, and invalid handles such as"alice"becamealice.test).What changed
Typed identifiers and
AtDatetime(models)ProfileView*.Did/HandleareDid/Handle, andindexedAt/createdAtareAtDatetime?.ViewerState.Blocking/Following/FollowedByareAtUri?.ProfileRecord.CreatedAtisAtDatetime?.PostView.Uri/Cid/IndexedAtandGeneratorView.Uri/Cid/Did/IndexedAtare typed.Like/RepostareAtUri?.ThreadgateRecord/PostgateRecord,Postis typed, and so areHiddenRepliesandDetachedEmbeddingUris(IReadOnlyList<AtUri>).NotFoundPost/BlockedPost/SkeletonFeedPost/DescribeFeedGenerator*are typed.CreatedAtisrequired AtDatetime.FollowRecord/BlockRecord/ListItemRecord.SubjectareDid, andListBlockRecord.Subject,ListItemRecord.ListandStarterPackRecord.ListareAtUri.ListViewerState.Blockedare typed.Relationship.Did/Following/FollowedBy,NotFoundActor.Actor(AtIdentifier) andGetRelationshipsResponse.Actorare typed.NotificationView.Uri/Cid/ReasonSubject/IndexedAt,ListNotificationsResponse.SeenAtandRegisterPushRequest.ServiceDidare typed.LabelerView*.Uri/Cid/IndexedAt,LabelerViewerState.LikeandLabelerServiceRecord.CreatedAtare typed.VideoView.Cid,MentionFeature.DidandJobStatus.Didare typed.string(the guard lists them):ExternalInfo.Uri,ExternalViewInfo.Uri,LinkFeature.UriandRichTextBuilder.Link(uri)are Lexiconuri, notat-uri.searchPostssince/untilacceptYYYY-MM-DD.Clients: typed parameters and one parameter order
actorparameters take anAtIdentifier. Post, feed, list, thread-root and starter-pack parameters take anAtUri,cidtakes aCid?,seenAttakes anAtDatetime?, andLabelerClient.GetServicesAsynctakesIEnumerable<Did>.IEnumerable<T>:GetProfilesAsync,GetPostsAsync,GetFeedGeneratorsAsync,GetRelationshipsAsync(others),GetStarterPacksAsync,PutPreferencesAsyncandGetServicesAsync.limit→cursor:GetTimelineAsync(algorithm, limit, cursor)GetAuthorFeedAsync(actor, filter, includePins, limit, cursor)ListNotificationsAsync(priority, seenAt, limit, cursor)RichTextBuilder.Mention(Handle, Did).Build()returnsIReadOnlyList<Facet>?as a snapshot, so appending afterBuild()no longer changes a list you already hold.LikeAsync/RepostAsync/FollowAsync/the delete-by-URI helpers andPostAsync's inputs. They now stampcreatedAtwithAtDatetime.Now(). Moving them toclient.Bskyis #122.ICursorPage<T>andEnumerate*app.bskyresponses implementICursorPage<T>, withItemsexplicit over the Lexicon-named list.Pagination.EnumerateAsync, which stops on a null, empty or repeated cursor:EnumerateSuggestionsAsync,EnumerateSearchActorsAsyncEnumerateTimelineAsync,EnumerateAuthorFeedAsync,EnumerateFeedAsync,EnumerateListFeedAsyncEnumerateActorLikesAsync,EnumerateLikesAsync,EnumerateRepostedByAsync,EnumerateQuotesAsyncEnumerateActorFeedsAsync,EnumerateSuggestedFeedsAsyncEnumerateSearchPostsAsyncEnumerateFollowersAsync,EnumerateFollowsAsync,EnumerateKnownFollowersAsyncEnumerateBlocksAsync,EnumerateMutesAsyncEnumerateListsAsync,EnumerateListMembersAsync,EnumerateListBlocksAsync,EnumerateListMutesAsyncEnumerateActorStarterPacksAsync,EnumerateSearchStarterPacksAsyncEnumerateNotificationsAsyncgetFeedSkeletonhas no enumerator, because it is a feed generator's own endpoint.Get*drops the verb (GetFollowers→EnumerateFollowers).Search*keeps it (EnumerateSearchPosts), so a search enumerator is never mistaken for a listing.getList→EnumerateListMembersAsync.Internal request DTOs
PutPreferencesRequest,MuteActorRequest,MuteActorListRequest,MuteThreadRequestandUpdateSeenRequestare now internal.RegisterPushRequeststays public, becauseRegisterPushAsyncaccepts it.Blazor (minimal; #136 rewires these)
FeedViewparses itsFeedSourcestring intoAtIdentifier/AtUriand passes paging arguments by name.PostCard.FormatTimetakes theAtDatetime.ProfileCard/PostCardreadHandle.Valuewhere they needed astring._Imports.razoraddsATProtoNet.Identity.Breaking changes and migration
One
Breaking changesbullet in CHANGELOG covers all of it:AtIdentifier.Parse("alice.bsky.social"),AtUri.Parse("at://…"),Did.Parse(…). ADidorHandleconverts implicitly toAtIdentifier, and every typed value converts implicitly tostring.AtDatetime.Now(), and read.Value/.TryGetValue(...)/.ToString().GetTimelineAsync,GetAuthorFeedAsyncandListNotificationsAsyncmust pass the moved arguments by name.List<T>→IReadOnlyList<T>. Copy ([.. page.Feed]) where you mutated.XrpcResponseFormatException, the part 1 behaviour. A malformeddatetimedoes not throw, becauseAtDatetimereads leniently.Verification
dotnet build --no-incremental(whole solution, including samples and integration tests): 0 warnings, 0 errors.TypedBskyClientTests(new, 13 tests) checks:uris,seenAtsent with its offset, and typedmentions/authorupdateSeen/muteActor/follow/createRecordcreatedAtfromPostAsynccreatedAtICursorPage<T>on every cursoredapp.bskytypeRichTextBuilderTestsgains a snapshot test.App.Bskyremoved from the pending list.Deferred / flagged
Wire-fidelity gaps noticed while converting. JSON names are unchanged here; these are for #123:
FeedClient.SearchPostsAsync(tag)is onestring, but the Lexicon'stagis an array (repeated key).listNotificationsreasons[]andgetListspurposes[].LikeRecord/RepostRecordviaLabelerServiceRecordlabels/reasonTypes/subjectTypes/subjectCollectionsRelationshipblocking/blockedBy/blockingByList/blockedByListStandardLabelValues.NotAvailableis"!no-unauthenticated", the same value asNoUnauthenticated. Its doc says takedown, so the value looks wrong.JsonElementrather than typed unions/refs:ThreadgateRecord.Allow,ListRecord.Labels,GetRelationshipsResponse.Relationships,FeedViewPost.Reason,LabelerView.Creator.docs/blazor.mdshows<ProfileCard Actor="…" />, but the component's parameter isProfile. This predates this PR; left to #136.Shared-file edits outside
app.bsky:TypedIdentifierGuardTests.cs: removed only theApp.Bskypending line, and added the eightApp.Bskyexceptions directly afterModels.Label.Uri.CHANGELOG.md: one bullet each under Breaking changes and Added.docs/identity-types.md: the "In Models and Clients" sentence now listsapp.bsky.*as converted. Parts 3 and 4 will edit the same sentence.AtProtoClient.cs: the facade helpers'createdAt, plus an unusedusingremoved.tests/…/Http/EmptyResponseBodyTests.cs: only theapp.bskycall sites. Itstools.ozonelines are untouched.🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
@claude /review — large and mostly mechanical; the description opens with How to review this.
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
This is exactly what the description says it is: a large, mechanical typed-identifier conversion for
app.bsky.*, following the pattern established in #154. I sampledActorClient,FeedClient,GraphClient,NotificationClient,LabelerClient,RichTextBuilder, and the model files, plus the Blazor call sites and docs.Things that check out:
GetTimelineAsync,GetAuthorFeedAsync,ListNotificationsAsync) is applied consistently at every call site, including the BlazorFeedView.razor, which correctly switches to named arguments where the positional order changed.ICursorPage<T>explicit implementations consistently pointItemsat the Lexicon-named list (Likes,Feed,Followers, etc.), and the newEnumerate*methods wire the right response/item type pairs throughPagination.EnumerateAsync.RichTextBuilder.Build()now returns a defensive copy ([.. _facets]), which actually fixes the aliasing bug called out in the PR description (appending afterBuild()no longer mutates a previously returned list).since/untilsearch-post filters and the genericuri-format link fields are deliberately left asstring, matching the stated Lexicon-format rationale, and thePutPreferencesRequest/MuteActorRequest/etc. becominginternallines up with the call sites now only constructing them via the client methods.AtProtoClient.csfacade swap fromAtProtoJsonDefaults.NowTimestamp()toAtDatetime.Now()is applied uniformly, and the unusedusing ATProtoNet.Serialization;removal is consistent with that.Breaking changesand anAddedbullet in the same commit as required by CLAUDE.md.The diff was truncated before
TypedIdentifierGuardTests.cs, the newTypedBskyClientTests.cs, and the tail of the call-site/doc changes, so I couldn't directly verify those, but the described verification (0 warnings/errors, 3175 tests passing including 14 new ones covering wire format and enumerator behavior) is consistent with what I could see in the sampled files. No correctness or security issues found in the reviewable portion.⏱ 0m 52s · 1 turns · tokens: 77.8k in / 5.3k out (+23.9k cache-read)
📜 Full session transcript
@ -67,3 +85,3 @@/// <param name="cancellationToken">Cancellation token.</param>public async Task UpdateSeenAsync(string seenAt, CancellationToken cancellationToken = default)AtDatetime seenAt, CancellationToken cancellationToken = default)Minor doc nit:
<see cref="AtDatetime.Now"/>references a method without parentheses; typically this would be writtenAtDatetime.Now()for clarity in the cref, though it should still resolve. Not a build-breaking issue since CS1591 only flags missing docs, not invalid crefs here.🔧 Let Claude fix this — tick the box and claude-bot picks up the review above, on this PR branch. Nothing runs until you do.
✅ Verdict: approve — no blocking issues found.
This is a clean, disciplined part-2 conversion. I verified the typings against the current upstream Lexicons (actor/graph/feed/notification defs and the
getRelationships,searchPosts,listNotificationsquery schemas) and every typing decision matches the Lexicon format — including the deliberate string exceptions (ExternalInfo.Uri,LinkFeature.Uriareuri,searchPostssince/untilare format-less because they accept a bare ISO date). TheICursorPage<T>implementations are consistent (all 22 cursored responses, explicitItemsover the Lexicon-named list), all 25 enumerators go through the sharedPagination.EnumerateAsyncwith the null/empty/repeated-cursor stop, parameter orders follow the CONTRIBUTING.md rule (subject → filters → limit → cursor), and the newly internal request DTOs have no remaining external call sites. The reflection guard now coversApp.Bskywith eight reasoned exceptions, andTypedBskyClientTestscovers the wire format (repeateduriskeys,seenAtas its original text), typed parsing, enumerator paging behavior (repeated cursor and per-page filters), the canonicalcreatedAtfrom the facade, the non-canonical round-trip, and the interface on every cursored type. TheRichTextBuilder.Build()snapshot fix is a genuine (small) bug fix. The Blazor/sample/docs call sites compile via the implicit string conversions, and docs and CHANGELOG match the code. No correctness issues found.Non-blocking notes for the #123 wire-fidelity list the PR already maintains:
listNotificationsnow marks itspriorityparameter as "Deprecated: this parameter is ignored" and itsseenAtparameter as "Deprecated: this parameter is unsupported and will cause an error."ListNotificationsAsync/EnumerateNotificationsAsyncstill expose both (they did before this PR too), and the doc comment forseenAt("filter new notifications since") no longer matches upstream behaviour — sendingseenAtwill error against the live Bluesky AppView. Worth adding to the Deferred/flagged list alongside the missingreasons[].GetUnreadCountAsync'sseenAtlikewise deserves a doc check against upstream when #123 touches this surface (the typedAtDatetimeitself is correct there).Everything else flagged in the description (missing fields, raw
JsonElementunions,tagas array,StandardLabelValues.NotAvailable) is correctly deferred with reasons.⏱ 6m 22s · 59 steps · $0.2245 · tokens: 343.3k in / 27.0k out (+5316.4k cache-read) · Go: 5h 2% · wk 99% · mo 82%
📜 Full session transcript
@ -26,3 +27,3 @@public Task<ListNotificationsResponse> ListNotificationsAsync(int? limit = null,bool? priority = null,AtDatetime? seenAt = null,Non-blocking, for #123: upstream's
app.bsky.notification.listNotificationsnow documentspriorityas "Deprecated: this parameter is ignored" andseenAtas "Deprecated: this parameter is unsupported and will cause an error", so sendingseenAtwill fail against the live Bluesky AppView. This parameter existed before this PR (as astring), so it is not introduced here, but the doc comment ("filter new notifications since…") no longer matches upstream and this pair belongs on the Deferred/flagged list next to the missingreasons[]parameter.🔧 Let opencode-bot fix this — tick the box and opencode-bot picks up the review above, on this PR branch. Nothing runs until you do.