feat(records)!: record and custom-XRPC ergonomics, Bluesky helpers on client.Bsky #165
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
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!165
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-122-records"
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?
Closes #122.
Record and custom-XRPC ergonomics, a public transport for third-party Lexicon sub-clients, the Bluesky helpers moved to
client.Bsky, one construction path forAtProtoClient, and the remaining duplicate defs merged. Breaking by design; every moved or renamed member has a migration note inCHANGELOG.md.Records
IAtProtoRecord(static abstract Nsid Collection) andclient.GetCollection<T>().GetCollection<T>(Nsid)stays for run-time collections and throwsArgumentExceptionwhenTdeclares a different collection (read through the interface map, so explicit implementations work too). The SDK's 25 record models (everyapp.bsky,chat.bskyandsite.standardrecord in the upstream snapshot, including #127'sstatus,contentVisibilityDeclaration,referencelistoptout,verificationandnotification.declaration) implement it, soclient.GetCollection<PostRecord>()works; theclient.Bskyhelpers use thoseCollections.atproto-lexgen csharpnow emits the interface andCollectionon generated records (verified: generatedapp.bsky.feed.likecompiles with warnings-as-errors and works withGetCollection<LikeRecord>()).createdAt.AtProtoRecord.CreatedAthas no initializer;CreateAsyncstamps it only whennull;PutAsyncwrites what it is given. Regression tests cover deserializing withoutcreatedAtand a read-then-put round trip.RecordRef(AtUri, Cid, CommitMeta?)(plusRecordKey,ValidationStatus,ToStrongRef()) and oneRecordView<T>(AtUri, Cid?, T), both sealed records. Every write returnsRecordRef(RecordCollection<T>,RepoClient.CreateRecordAsync/PutRecordAsync,client.Bskyhelpers,StandardSiteClient); every typed read returnsRecordView<T>(untypedGetRecordAsyncreturnsRecordView<JsonElement>).CreateRecordResponse/PutRecordResponse(one internalRecordWriteResponse) andGetRecordResponse<T>are internal wire DTOs; the non-genericGetRecordResponseis gone.Embed.RecordViewis renamedRecordEmbedView(the core type keeps the name the issue specifies).Tvia internalGetRecordResponse<T>/ListRecordsResponse<T>, noJsonElementclone and second pass. A bad record still throwsXrpcResponseFormatException, naming the record URI (get) or the collection and JSON path (list).FindAsync/FindFromAsyncreturnnullonRecordNotFoundonly;ExistsAsyncisFindAsync(...) is not null.Custom XRPC and extensibility
QueryAsync<TOut>(Nsid, XrpcParams?, XrpcCallOptions?, ct),ProcedureAsync<TIn, TOut>,ProcedureAsync<TIn>,ProcedureAsync(Nsid, XrpcParams?, …). The anonymous-object overload is kept asQueryAsync<TOut>(Nsid, object, …)with[RequiresUnreferencedCode](existing call sites keep compiling). The no-inputProcedureAsynccarries[OverloadResolutionPriority(1)]soProcedureAsync(nsid, xrpcParams)is not ambiguous withProcedureAsync<TIn>.IXrpcTransport(QueryAsync,DownloadAsync, the threeProcedureAsync,UploadAsyncwith optional query parameters, matching #164's multipart upload) exposed asAtProtoClient.Transport.XrpcClientstays internal and implements it explicitly.XrpcParamsis public (collection-initializer friendly).docs/custom-xrpc.mdhas a complete third-party sub-client example (models, sub-client, extension method, NSubstitute test).EnsureAuthenticatedis replaced byRequireSession()/RequireDid(), which read the session once and throwXrpcAuthenticationException(AuthenticationRequired, 401)with a message that fits password and OAuth sessions.Facade
client.Bsky(in a separate partial file,BlueskyClients.Helpers.cs; #164's new sub-client registrations stay in the main part inAtProtoClient.cs):PostAsync(RichText, PostOptions?),LikeAsync(StrongRef),RepostAsync(StrongRef),FollowAsync(Did),DeleteRecordAsync(AtUri)(validates collection + rkey before any request),UpdateProfileAsync.RichTextis a sealed record with implicit conversion fromstring;RichTextBuilder.Build()returns it (still deconstructs).AtProtoClient(CreateFirehoseClient/CreateFirehoseConsumer/RelayUrl, and the AspireAtProtoClientSettings.RelayUrl).AtProtoClientBuilderremoved; one constructorAtProtoClient(AtProtoClientOptions? = null, HttpClient? = null, IAtProtoSessionStore? = null, ILogger<AtProtoClient>? = null).SetLabelerscollapsed to oneparams IEnumerable<string>method.Duplicate defs
CreatePdsAccountRequest→CreateAccountRequest(PdsAdminClient forwards every field; password still required).BlobLink→CidLink.ModerationSubjectinCom.AtProto.Moderation(repoRef, strongRef, the newRepoBlobSubject, messageRef, convoRef, unknown) replacesReportSubjectand the Ozone copy;CreateReportResponse.Subjectand the admin subject-statusSubjects are typed instead ofJsonElement;PdsAdminClient.CreateRepoRefdeleted.SessionResponsevsSession(#124 removedSession;SessionResponseis the only createSession/refreshSession wire DTO),TeamMemberProfileandAccountResult(#123).Breaking changes (migration)
See the 16
Breaking changesbullets tagged (#122) inCHANGELOG.md; in short:new AtProtoClientBuilder().WithInstanceUrl(u).Build()→new AtProtoClient(new AtProtoClientOptions { InstanceUrl = u }).client.PostAsync(text, facets, embed: e)→client.Bsky.PostAsync(new RichText(text, facets), new PostOptions { Embed = e });LikeAsync(uri, cid)→Bsky.LikeAsync(new StrongRef { Uri = uri, Cid = cid });UnlikeAsync/UndoRepostAsync/UnfollowAsync/DeletePostAsync(uri)→Bsky.DeleteRecordAsync(uri);UpdateProfileAsync→Bsky.UpdateProfileAsync.ProcedureAsync<Out>(nsid, body)→ProcedureAsync<In, Out>(nsid, body); passXrpcCallOptionsby name.CreatedAtisnulluntil set; set it beforePutAsync/RepoClientwrites of new records.CreateRecordResponse/PutRecordResponse/GetRecordResponse<T>→RecordRef/RecordView<T>;Embed.RecordView→RecordEmbedView.InvalidOperationException("Not authenticated…")→XrpcAuthenticationException(AuthenticationRequired).CreatePdsAccountRequest→CreateAccountRequest;BlobLink→CidLink;ReportSubject→ModerationSubject(Ozone subject types move toATProtoNet.Lexicon.Com.AtProto.Moderation).CreateFirehoseClient()→new FirehoseClient(relayUrl, logger).Verification
dotnet build(full solution): 0 warnings, 0 errors.dotnet build --no-incremental: 0 warnings, 0 errors.origin/mainat4955389, after #161, #162, #163, #164).LexiconDriftTests.Records): every SDK type that is anAtProtoRecordor writes a$typethat the upstream snapshot defines as a record must implementIAtProtoRecordwithCollectionequal to that$type(25 found; the SDK has noAtProtoRecordsubclasses of its own, so the scan keys on upstream record kind), and everyIAtProtoRecordmust name an upstream record. Mutation-checked: dropping the interface from one model fails the test.Http/CustomXrpcTests(12: query params incl. anonymous overload, the three procedure shapes, null input, format errors, a third-party sub-client overclient.Transportfollowing sign-in/sign-out, download/upload, fakingIXrpcTransport),BlueskyHelpersTests(post/like/repost/follow/delete/validation/unauthenticated + the moved profile tests),AtProtoClientConstructionTests, and additions toRecordCollectionTests/RecordCollectionErrorTests(GetCollection checks, RecordRef/RecordView, createdAt stamping and non-fabrication, FindAsync, typed list, format errors, no-session behaviour),RichTextBuilderTests,LexiconUnionTests(RepoBlobSubject, typed admin subject),CSharpEmitterTests.RecordWriteResponse→createRecord#output,ListRecordsResponse<>→listRecords#output,BlobLinkentry removed.Deliberately deferred / flagged
SetProxy/ClearProxy/SetLabelers/ClearLabelerskept. The issue removes forwarders "now covered byXrpcCallOptions", but the Lexicon sub-client methods (client.Bsky.Feed…etc.) don't takeXrpcCallOptions, so these client-wide defaults are the only way to send labelers/proxy on those calls (docs/labeler.md relies on it). Only the duplicateSetLabelersoverload was collapsed.LatestRepoRev/LatestRateLimitInfoare status, not forwarders, and stay.com.atproto.lexicon.schemais the one upstream record the SDK doesn't model yet (lexicon-resolution issue);StandardSiteClientkeeps its own private NSID fields (could switch toPublicationRecord.Collectionand friends).RichTextlives in namespaceATProtoNet.Lexicon.App.Bsky.RichText(type and namespace share a name, as the issue names it); code inside a namespace with aRichTextorLexiconchild may need an alias.Edits outside this issue's area (unavoidable call sites)
Lexicon/App/Bsky,Lexicon/Chat/Bsky/Actor,Lexicon/Site/Standard/**models:IAtProtoRecord+static Nsid Collectionon each record (andType => Collection).Lexicon/App/Bsky:RichText.cs(new),RichTextBuilder.Build(),Embed.RecordViewrename, one cref inActorModels.cs.Lexicon/Tools/Ozone/Moderation/ModerationModels.cs: subject copies removed, oneusing.Lexicon/Com/AtProto/Admin/AdminModels.cs: subject types +using.Lexicon/Site/Standard/StandardSiteClient.cs: return types and the private list helper.RepoClient.cs: record methods only,importRepountouched.BlueskyClientsinAtProtoClient.cs:partialkeyword only.🤖 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.
⏳ Held on the OpenCode Go window — the 5-hour window is at 0% (the bot holds at 95%).
This continues automatically after the window resets (2026-09-26T05:12:10.267Z) — nothing to re-trigger.
@claude /review — large; the description has the migration notes. Please read these in full from the branch:
IXrpcTransport(the public seam) and its implementation,RecordCollection<T>(single deserialization,FindAsync), theRecordRef/RecordView<T>types, and theGetCollection<T>()NSID consistency check.🤖 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 a large, well-executed ergonomics/breaking-change PR, and the parts I inspected (both in the diff and by pulling the full files from
origin/issue-122-recordswhere the diff was truncated) hold up well.Spot-checked in depth:
IAtProtoRecord/RecordPaths.DeclaredCollection<T>()— reads the static abstractCollectionmember throughGetInterfaceMap, correctly guards againstTbeing an interface or not implementingIAtProtoRecord, and caches the result per closed generic type.GetCollection<T>(Nsid)correctly cross-checks a declared collection against the explicit one and throwsArgumentExceptionon mismatch;GetCollection<T>()readsT.Collectiondirectly via the interface constraint.RecordRef/RecordView<T>constructors validate the URI names a record viaRecordPaths.PathOf, andToStrongRef()is correct.RecordCollection<T>.CreateAsynconly stampsCreatedAtwhen null, matching the changelog's "no fabricated createdAt" claim;FindAsync/FindFromAsynccorrectly narrow the catch toRecordNotFoundonly.AtProtoClientconstructor ordering is correct:Bsky(and its constituent sub-clients) is fully built beforeBsky.Bind(this)is called, andRequireSession/RequireDidthrowXrpcAuthenticationExceptioncleanly before any request is sent.IXrpcTransport/XrpcClientexplicit interface implementation forwards to the existing internal methods correctly, including the[OverloadResolutionPriority(1)]disambiguation betweenProcedureAsync(nsid, XrpcParams?)andProcedureAsync<TIn>(nsid, input)(a real ambiguity that this attribute resolves cleanly on .NET 10/C# 13+).PdsAdminClient.CreateAccountAsync's invite-code minting path correctly copies the request rather than mutating a caller-owned object, and forwards every field including the newVerificationCode/VerificationPhone/PlcOp.I didn't find any correctness bugs, and the breaking changes are all documented with migration notes as required by CLAUDE.md. Given the scope, I did not re-derive every one of the ~25 record models or the full
ModerationSubjectunion by hand, but the pattern used throughout is consistent and the PR author claims mutation-tested drift-test coverage for exactly that surface.⏱ 1m 37s · 9 turns · tokens: 94.6k in / 6.4k out (+911.2k cache-read)
📜 Full session transcript