feat!: typed identifiers and uniform conventions across the public API #119

Closed
opened 2026-09-24 12:39:03 +00:00 by Grandiras · 1 comment
Owner

Part of the v0.7.0 update cycle (wave 3). Owner decision: typed identifiers everywhere. This is the one large, deliberate break that gives the SDK a type-safe surface. It lands after the identifier fixes (#114), the open-union/extension-data foundation (#115) and the transport/error rework, and before the lexicon additions, so new models are written typed from the start.

Typed identifiers

  • Every model property carrying a DID, handle, at-identifier, AT URI, NSID, CID, record key or TID uses the typed type. About 196 string properties exist today against 8 typed ones. Update the JSON converters accordingly; they throw JsonException on invalid input.
  • Add AtDatetime (readonly record struct) for Lexicon datetime fields. It preserves the original string so records round-trip byte-for-byte (and therefore CID-stable). It parses leniently on read (exposing TryGetValue/Value), is strict on construction, and has Now() and ToString(). AtProtoRecord.CreatedAt becomes AtDatetime?.
  • Every client method takes typed identifiers (RepoClient.CreateRecordAsync(AtIdentifier repo, Nsid collection, …, RecordKey? rkey, …, Cid? swapCommit), GetProfileAsync(AtIdentifier actor), …). Convenience overloads taking AtUri where the lexicon names a record. AtProtoClient.Did/Handle become Did?/Handle?.
  • Standardize on one name for the record key (the lexicon field rkey → property Rkey, type RecordKey). Today RKey, Rkey, rkey and RecordKey all appear.

Uniform conventions (while every signature is being touched)

  • Parameter order: subject identifiers, then required inputs, then optional filters, then limit, then cursor, then CancellationToken. Today at least three methods order them differently.
  • ICursorPage<T> { IReadOnlyList<T> Items; string? Cursor; } implemented by every cursored response. Today only 2 of about 50 implement ICursoredResponse.
  • Enumerate* methods returning IAsyncEnumerable<T> through one internal paginator that stops on a null, empty or repeated cursor. A server repeating its cursor currently drives about 160k requests per second. Rename ListAll* to Enumerate*. Add enumerators for timeline, author feed, followers, follows, likes, notifications, list members, records and convo messages.
  • Public model collections are IReadOnlyList<T> for outputs; public method inputs take IEnumerable<T>/IReadOnlyCollection<T>.
  • The 49 single-use *Request DTOs that are never accepted by a public method become internal.
  • Drop SpaceUri overloads made redundant by typed parameters.

Generator

  • atproto-lexgen csharp's TypeMapper maps string formats to the typed identifiers and AtDatetime, and arrays to IReadOnlyList<T>, so generated code matches the SDK.

Acceptance

  • No public model property or client parameter carries a known identifier format as string. Add a reflection test that asserts this, with an explicit allow-list for deliberate exceptions.
  • All samples, integration tests and docs compile or are updated.
  • CHANGELOG Breaking changes with a migration section (Did.Parse("…") at literal call sites, AtDatetime, parameter reordering, Enumerate* renames).
Part of the v0.7.0 update cycle (wave 3). **Owner decision: typed identifiers everywhere.** This is the one large, deliberate break that gives the SDK a type-safe surface. It lands after the identifier fixes (#114), the open-union/extension-data foundation (#115) and the transport/error rework, and before the lexicon additions, so new models are written typed from the start. ## Typed identifiers - [ ] Every model property carrying a DID, handle, at-identifier, AT URI, NSID, CID, record key or TID uses the typed type. About 196 `string` properties exist today against 8 typed ones. Update the JSON converters accordingly; they throw `JsonException` on invalid input. - [ ] Add `AtDatetime` (`readonly record struct`) for Lexicon `datetime` fields. It **preserves the original string** so records round-trip byte-for-byte (and therefore CID-stable). It parses leniently on read (exposing `TryGetValue`/`Value`), is strict on construction, and has `Now()` and `ToString()`. `AtProtoRecord.CreatedAt` becomes `AtDatetime?`. - [ ] Every client method takes typed identifiers (`RepoClient.CreateRecordAsync(AtIdentifier repo, Nsid collection, …, RecordKey? rkey, …, Cid? swapCommit)`, `GetProfileAsync(AtIdentifier actor)`, …). Convenience overloads taking `AtUri` where the lexicon names a record. `AtProtoClient.Did`/`Handle` become `Did?`/`Handle?`. - [ ] Standardize on one name for the record key (the lexicon field `rkey` → property `Rkey`, type `RecordKey`). Today `RKey`, `Rkey`, `rkey` and `RecordKey` all appear. ## Uniform conventions (while every signature is being touched) - [ ] Parameter order: subject identifiers, then required inputs, then optional filters, then `limit`, then `cursor`, then `CancellationToken`. Today at least three methods order them differently. - [ ] `ICursorPage<T> { IReadOnlyList<T> Items; string? Cursor; }` implemented by **every** cursored response. Today only 2 of about 50 implement `ICursoredResponse`. - [ ] `Enumerate*` methods returning `IAsyncEnumerable<T>` through one internal paginator that stops on a null, empty or **repeated** cursor. A server repeating its cursor currently drives about 160k requests per second. Rename `ListAll*` to `Enumerate*`. Add enumerators for timeline, author feed, followers, follows, likes, notifications, list members, records and convo messages. - [ ] Public model collections are `IReadOnlyList<T>` for outputs; public method inputs take `IEnumerable<T>`/`IReadOnlyCollection<T>`. - [ ] The 49 single-use `*Request` DTOs that are never accepted by a public method become `internal`. - [ ] Drop `SpaceUri` overloads made redundant by typed parameters. ## Generator - [ ] `atproto-lexgen csharp`'s `TypeMapper` maps string formats to the typed identifiers and `AtDatetime`, and arrays to `IReadOnlyList<T>`, so generated code matches the SDK. ## Acceptance - No public model property or client parameter carries a known identifier format as `string`. Add a reflection test that asserts this, with an explicit allow-list for deliberate exceptions. - All samples, integration tests and docs compile or are updated. - CHANGELOG `Breaking changes` with a migration section (`Did.Parse("…")` at literal call sites, `AtDatetime`, parameter reordering, `Enumerate*` renames).
Author
Owner

Delivery plan: four PRs. This change touches every model and client, so it lands in four steps:

  1. Foundation + com.atproto
    • AtDatetime.
    • ICursorPage<T> plus the internal paginator (stops on a null, empty or repeated cursor); the Enumerate* naming.
    • The parameter-order and collection-type conventions, written down in CONTRIBUTING.md.
    • The generator TypeMapper.
    • A reflection guard test that fails on any identifier-format property typed as string. It starts with a temporary namespace allow-list that later PRs shrink to empty.
    • Conversions: Models/CommonModels, RecordCollection, the AtProtoClient facade, Lexicon/Com/AtProto/{Repo,Server,Identity,Sync,Admin,Label,Moderation}, PdsAdminClient.
  2. app.bsky.* (Lexicon/App/Bsky/**), plus the Blazor components that consume it.
  3. chat.bsky.*, tools.ozone.*, site.standard.*.
  4. Spaces and streaming: Lexicon/Com/AtProto/{Space,SimpleSpace}, src/ATProtoNet/Spaces/*, ATProtoNet.Server/Spaces/*, and the Jetstream and firehose models. The last PR empties the allow-list and closes this issue.

PRs 2–4 run in parallel once 1 has merged. DidDocument / PLC models are left to #120, which replaces them.

**Delivery plan: four PRs.** This change touches every model and client, so it lands in four steps: 1. **Foundation + `com.atproto`** - `AtDatetime`. - `ICursorPage<T>` plus the internal paginator (stops on a null, empty or repeated cursor); the `Enumerate*` naming. - The parameter-order and collection-type conventions, written down in `CONTRIBUTING.md`. - The generator `TypeMapper`. - A **reflection guard test** that fails on any identifier-format property typed as `string`. It starts with a temporary namespace allow-list that later PRs shrink to empty. - Conversions: `Models/CommonModels`, `RecordCollection`, the `AtProtoClient` facade, `Lexicon/Com/AtProto/{Repo,Server,Identity,Sync,Admin,Label,Moderation}`, `PdsAdminClient`. 2. **`app.bsky.*`** (Lexicon/App/Bsky/**), plus the Blazor components that consume it. 3. **`chat.bsky.*`, `tools.ozone.*`, `site.standard.*`.** 4. **Spaces and streaming:** `Lexicon/Com/AtProto/{Space,SimpleSpace}`, `src/ATProtoNet/Spaces/*`, `ATProtoNet.Server/Spaces/*`, and the Jetstream and firehose models. The last PR empties the allow-list and closes this issue. PRs 2–4 run in parallel once 1 has merged. `DidDocument` / PLC models are left to #120, which replaces them.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#119
No description provided.