Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .claude/skills/uts-to-csharp/references/realtime-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ SDK facts: the WebSocket URL carries `v=6`, `format=json` and `heartbeats=true`

**Consolidations (apply everywhere):**

1. **`Result` versus throw.** Attach, detach, `setOptions`, publish, update/delete/append, presence enter/update/leave (and the `…Client` variants), annotations publish/delete and connection `ping` return `Result` / `Result<T>` and normally don't throw: assert `IsSuccess` / `Error`. Exceptions: `PublishAsync` throws when its precondition check fails (`EnsureCanPublish`; wrapped as 50000 on the `TaskWrapper` path), and `channel.Presence.GetAsync`, presence `HistoryAsync`, `Auth.AuthorizeAsync` and every REST-backed method throw `AblyException` (§4).
1. **`Result` versus throw.** Attach, detach, `setOptions`, publish, update/delete/append, presence enter/update/leave (and the `…Client` variants), annotations publish/delete and connection `ping` return `Result` / `Result<T>` and normally don't throw: assert `IsSuccess` / `Error`. Exceptions: `PublishAsync`, update/delete/append and annotations publish throw when the state precondition check fails (`EnsureCanPublish`; the refusal's own `ErrorInfo`, code 40000), and `channel.Presence.GetAsync`, presence `HistoryAsync`, `Auth.AuthorizeAsync` and every REST-backed method throw `AblyException` (§4).
2. **JSON only.** Msgpack inputs are not applicable (§12).
3. **LiveObjects is out of scope.** `channel.object` and the `objects` module are unready in the mapping; `OBJECT` / `OBJECT_SYNC` are wire constants only.

Expand Down Expand Up @@ -90,7 +90,7 @@ REST options and the log-level mapping are in `rest-mapping.md` §3; every durat
- **`client.connect()` / `client.close()` / `connection.connect()` / `connection.close()`** → `client.Connect()` / `client.Close()` / `client.Connection.Connect()` / `Close()`: `void`, they queue a workflow command. Follow with a state wait: `AWAIT_STATE client.connection.state == ConnectionState.connected` (both spellings, and bare `CONNECTED`) → `await UtsWait.AwaitConnectionState(client.Connection, ConnectionState.Connected)`; channels → `await UtsWait.AwaitChannelState(channel, ChannelState.Attached)`. Both subscribe first, then check, and fail fast on FAILED (README "Waits, time and assertions").
- **Channel and presence operations return `Task<Result>`** (or `Task<Result<T>>`) and normally **don't throw**: `AttachAsync`, `DetachAsync`, `SetOptionsAsync`, `PublishAsync` (`Result<PublishResult>`), `UpdateMessageAsync`/`DeleteMessageAsync`/`AppendMessageAsync` (`Result<UpdateDeleteResult>`), presence `EnterAsync`/`UpdateAsync`/`LeaveAsync` and the `…ClientAsync` forms, annotations `PublishAsync`/`DeleteAsync`, `Connection.PingAsync` (`Result<TimeSpan?>`).
- `AWAIT channel.attach()` (expected to succeed) → `var attachResult = await channel.AttachAsync(); attachResult.IsSuccess.Should().BeTrue(…);` (unit) or `AssertSucceeded(attachResult, "attach")` (integration base): an `AWAIT` that fails must fail the test.
- **Exceptions on the `Result` path:** `PublishAsync` throws when the precondition check fails (`RealtimeChannel.EnsureCanPublish`: channel SUSPENDED or FAILED, or the connection can't queue because `QueueMessages` is false and it isn't CONNECTED). The `TaskWrapper` path wraps that in `new AblyException(ex)`: the thrown `ErrorInfo` is code 50000, message `"Unexpected error :…"`, with the original `AblyException` as `InnerException`. Render those `FAILS WITH` cases with the throwing form (`(await act.Should().ThrowAsync<AblyException>()).Which.ErrorInfo`, see SKILL.md *Assertions (FluentAssertions)*) and assert the outer `ErrorInfo` as the spec states it; a mismatch is a deviation candidate (§13), not a reason to read the inner one. A disposed client throws `ObjectDisposedException` from `Connect`/`Close`/`PingAsync`. A publish that gets no ACK/NACK within `RealtimeRequestTimeout` resolves (on the fake clock) to a failed `Result` whose `ErrorInfo` has code 0 and the message `"PublishAsync timeout expired. Message was not confirmed by the server"`.
- **Exceptions on the `Result` path:** `PublishAsync` (and update/delete/append and annotations publish) throws when the precondition check fails (`RealtimeChannel.EnsureCanPublish`: channel SUSPENDED or FAILED, or the connection can't queue because `QueueMessages` is false and it isn't CONNECTED). The thrown `AblyException` carries the refusal's own `ErrorInfo` (code 40000, statusCode 400, no `InnerException`); the task wrappers pass an `AblyException` through and wrap only a non-Ably exception (code 50000, message `"Unexpected error :…"`). Render those `FAILS WITH` cases with the throwing form (`(await act.Should().ThrowAsync<AblyException>()).Which.ErrorInfo`, see SKILL.md *Assertions (FluentAssertions)*) and assert the `ErrorInfo` as the spec states it; a mismatch is a deviation candidate (§13). A disposed client throws `ObjectDisposedException` from `Connect`/`Close`/`PingAsync`. A publish that gets no ACK/NACK within `RealtimeRequestTimeout` resolves (on the fake clock) to a failed `Result` whose `ErrorInfo` has code 0 and the message `"PublishAsync timeout expired. Message was not confirmed by the server"`.
- **Throwing realtime APIs:** `client.Auth.AuthorizeAsync` (RTC8), `channel.Presence.GetAsync` (RTP11: FAILED/DETACHED channel → 90001-range error; SUSPENDED with `waitForSync` → 91005), presence `HistoryAsync`, and every REST-backed method.
- **Callbacks** (`Connection.On`, `channel.On`, `Subscribe`, presence/annotation subscribe, `authCallback`) run on the workflow thread or a timer/HTTP continuation, concurrently with the test body (README "Threading model"). Capture into `CaptureList<T>` or `UtsRecord.*`; never block inside a callback. A `MockWebSocket` handler runs synchronously inside the SDK's `Connect`/`Send` and must not block or await.
- **Listener overloads:** `IEventEmitter` has both `Action<T>` and `Func<T, Task>` overloads. A statement or `void` expression lambda binds the `Action`; `async change => …` binds the `Func`. Use non-async lambdas unless the spec's handler awaits.
Expand Down Expand Up @@ -177,7 +177,7 @@ Every row of §6 to §9 was read in the SDK source.
|---|---|---|
| `channel.annotations.publish(serialOrMessage, annotation)`, `.delete(…)` | `await channel.Annotations.PublishAsync(serial, annotation)`, `DeleteAsync(…)` | `Result` |
| `channel.annotations.subscribe(cb)` / `(type:, cb)`, `.unsubscribe(…)` | `channel.Annotations.Subscribe(a => …)` / `Subscribe(type, a => …)`, `Unsubscribe(…)` (`bool`) | implicit attach; logs a warning (RTAN4e) when ANNOTATION_SUBSCRIBE wasn't granted |
| `channel.annotations.get(…)` | `GetAsync(serial, AnnotationsRequestParams)` | REST |
| `channel.annotations.get(…)` | `GetAsync(serial, AnnotationsRequestParams)` (`params:` → `ExtraParameters`, as REST) | REST |

## 10. Wire protocol in mocks <a id="10-wire-protocol"></a>

Expand Down Expand Up @@ -291,7 +291,7 @@ SDK behaviour a translator meets, read from the source. These are non-binding: t

> **RTL7g, implicit attach states.** `Subscribe` (messages, presence, annotations) attaches whenever the channel is not ATTACHED or ATTACHING, including SUSPENDED and FAILED; RTL7g lists INITIALIZED, DETACHING and DETACHED.

> **Publish precondition errors.** Thrown and wrapped as 50000 (§4); a spec expecting the precondition's own code on the returned error is a deviation candidate.
> **Publish precondition errors.** Thrown, not returned, with the refusal's own code 40000 and statusCode 400 (§4); a spec expecting the error on a failed `Result` instead is rendered with the throwing form.

> **RSA4e on a REST request inside a realtime spec.** 80019, not 40170 (`rest-mapping.md` §7).

Expand Down
5 changes: 2 additions & 3 deletions .claude/skills/uts-to-csharp/references/rest-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Options are PascalCase properties; every duration is a `TimeSpan` (spec values a
| `defaultTokenParams` | `DefaultTokenParams = new TokenParams { … }` | `TokenParams` |
| `clientId` | `ClientId` | `string`; the setter throws `InvalidOperationException` for `"*"` (RSA7c) |
| `endpoint` | `Endpoint` | `string`: routing policy (`main`), `nonprod:<id>`, or a hostname (contains `.` or `::`, or is `localhost`) with no default fallbacks (REC1) |
| `fallbackHosts` | `FallbackHosts = new[] { … }` | `string[]`, **set-only**; an empty array disables fallback. No public getter (§8) |
| `fallbackHosts` | `FallbackHosts = new[] { … }` | `string[]`; an empty array disables fallback. Reading `options.FallbackHosts` gives the array that was set, `null` when unset (TO3k6) |
| `port`, `tlsPort`, `tls` | `Port`, `TlsPort`, `Tls` | `int`, `int`, `bool` |
| `httpOpenTimeout`, `httpRequestTimeout` | `HttpOpenTimeout`, `HttpRequestTimeout` | `TimeSpan` (defaults 4 s, 10 s) |
| `httpMaxRetryCount` | `HttpMaxRetryCount` | `int` (default 3) |
Expand Down Expand Up @@ -194,7 +194,7 @@ Every row was read in the SDK source. "Throws" means `AblyException`.
| Pseudocode | C# | Result / failure |
|---|---|---|
| `channel.annotations.publish(serialOrMsg, Annotation(type:, name:, …))`, `.delete(…)` | `channel.Annotations.PublishAsync(serial, new Annotation(type, name, data, count, clientId))`, `DeleteAsync(…)` | `Task`; throws |
| `channel.annotations.get(serial, params)` | `channel.Annotations.GetAsync(serial, new AnnotationsRequestParams { Limit = n })` | `PaginatedResult<Annotation>` |
| `channel.annotations.get(serial, params: { "k": "v" })` | `channel.Annotations.GetAsync(serial, new AnnotationsRequestParams { ExtraParameters = new Dictionary<string, string> { ["k"] = "v" } })` | `PaginatedResult<Annotation>`; every entry, `limit` included, goes to the querystring as given; a typed `Limit`, when set, replaces a `limit` entry (the typed `Limit` is the idiomatic alternative) |

### Auth & tokens

Expand Down Expand Up @@ -332,7 +332,6 @@ The SDK replaced `environment`, `restHost`, `realtimeHost` and `fallbackHostsUse
| `PaginatedResult#first` | TG5 (UTS label TG4) | `rest/unit/TG4/first-returns-first-page-0`, `rest/integration/TG4/first-retrieves-page-0` | adapted: `channel.HistoryAsync(page.FirstQueryParams)` re-runs the first-page query, marked `// DEVIATION: PaginatedResult#first absent; using channel.HistoryAsync(page2.FirstQueryParams)`; Adapted Test. The unit test is gated on the TG link-parser deviation |
| `Auth#tokenDetails` (public) | RSA16 | every `client.auth.tokenDetails` read. The value exists internally (`((AblyAuth)client.Auth).CurrentToken`) | adapted: read the internal one at the unit tier, marked `// DEVIATION: Auth#tokenDetails absent; using internal AblyAuth.CurrentToken` at each test's first read; Adapted Test noting the public accessor is absent |
| `version` argument of `request()` | RSC19f, RSC19f1 | the SDK owner's idiom is a request-level `X-Ably-Version` header (`headers: {"X-Ably-Version": "3"}` takes precedence over the client default, RSC7e) | adapted wherever the spec passes `version`: add the header, marked `// DEVIATION: RestClient#request(version) absent; using a request-level X-Ably-Version header`; `rest/unit/RSC19f1/version-param-sets-header-0` is the Adapted Test entry |
| `ClientOptions.fallbackHosts` getter | TO3k6 | `rest/unit/TO3/client-options-custom-hosts-1` | adapted at the unit tier: read the internal `options.GetFallbackHosts()` (it returns the array that was set), marked `// DEVIATION: ClientOptions#fallbackHosts absent; using internal ClientOptions.GetFallbackHosts()`; Adapted Test noting the public getter is absent. At integration or proxy tier, where internals are out of bounds, comment the line out under the skip-reason comment instead |

### Mock Infrastructure Limitations

Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Ably Pub/Sub 2.0 splits the SDK into device-side and server-side packages so tha
- Clients are constructed through the door factories only: `PubSubDevice.CreateClient(...)`, `PubSubServer.CreateRealtimeClient(...)`, `PubSubServer.CreateHttpClient(...)`. The client constructors are internal in 2.0, so a client that is not classified as device- or server-side (which the platform's behaviour and MAU-based billing depend on) cannot be constructed from application code.
- The client-facing "REST" identifiers are renamed to "HTTP", matching the other Ably Pub/Sub SDKs: `AblyRealtime` → `PubSubRealtimeClient`, `AblyRest` → `PubSubHttpClient`, `IRealtimeClient` → `IPubSubRealtimeClient`, `IRestClient` → `IPubSubHttpClient`, `RestChannel`/`RestChannels`/`IRestChannel` → `HttpChannel`/`HttpChannels`/`IHttpChannel`, and the realtime client's `RestClient` property → `HttpClient`. Names that refer to Ably's REST API service or wire options (`ClientOptions.IdempotentRestPublishing`, ...) are unchanged.
- Every member the 1.x line had marked `[Obsolete]` is removed (`Auth.Authorise[Async]`, `Connection.RecoveryKey`, `ClientOptions.FallbackHostsUseDefault`, `ClientOptions.CaptureCurrentSynchronizationContext`, the legacy `CreateTokenRequestObject[Async]` and string-method `Request` overloads, `HistoryRequestParams`, the `HistoryAsync(untilAttach)` overloads and `Presence.IsSyncComplete`). See the "Deprecated API removed" table in [UPDATING.md](UPDATING.md).
- `ClientOptions.Endpoint` replaces `ClientOptions.Environment`, `RestHost` and `RealtimeHost`, which are removed (REC1/REC2, ADR-119). REST requests and the realtime connection now share one primary domain, `main.realtime.ably.net` by default, with the fallback hosts `main.[a-e].fallback.ably-realtime.com`; `Endpoint` also takes a routing policy name (`"acme"`), a non-production one (`"nonprod:sandbox"`) or a hostname. An explicit `FallbackHosts` always replaces the defaults, and a custom `Port`/`TlsPort` no longer disables them. `ClientOptions.FullRestHost()` and `FullRealtimeHost()` are removed and `ClientOptions.GetFallbackHosts()` is no longer public. See "Choose the host with `Endpoint`" in [UPDATING.md](UPDATING.md).
- `ClientOptions.Endpoint` replaces `ClientOptions.Environment`, `RestHost` and `RealtimeHost`, which are removed (REC1/REC2, ADR-119). REST requests and the realtime connection now share one primary domain, `main.realtime.ably.net` by default, with the fallback hosts `main.[a-e].fallback.ably-realtime.com`; `Endpoint` also takes a routing policy name (`"acme"`), a non-production one (`"nonprod:sandbox"`) or a hostname. An explicit `FallbackHosts` always replaces the defaults, and a custom `Port`/`TlsPort` no longer disables them. `ClientOptions.FullRestHost()` and `FullRealtimeHost()` are removed and `ClientOptions.GetFallbackHosts()` is no longer public; `ClientOptions.FallbackHosts` now has a public getter instead, which returns only the hosts you set (`null` when unset), not the computed defaults that `GetFallbackHosts()` returned (TO3k6). See "Choose the host with `Endpoint`" in [UPDATING.md](UPDATING.md).
- The root namespace moves from `IO.Ably` to `Ably.PubSub`, so namespace, assembly names and package ids all agree. Sub-namespaces map one-to-one (`IO.Ably.Realtime` → `Ably.PubSub.Realtime`, ...), with `IO.Ably.Rest` becoming `Ably.PubSub.Http` and the door namespaces simplifying to `Ably.PubSub.Device`/`Ably.PubSub.Server`. As a consequence, v1 (`IO.Ably.*`) and v2 (`Ably.PubSub.*`) types no longer collide, so a dependency still on `ably.io` 1.x does not block compiling against 2.0 — see [UPDATING.md](UPDATING.md) for why a mixed graph should still be temporary.
- Publishing returns the result of the publish: `IHttpChannel.PublishAsync` returns `Task<PublishResult>` (was `Task`), `IHttpChannel.Publish` returns `PublishResult` (was `void`), and `IRealtimeChannel.PublishAsync` returns `Task<Result<PublishResult>>` (was `Task<Result>`). `PublishResult.Serials` holds the serial of each published message (RSL1n, RTL6j). Typical callers only need to recompile.
- A successful REST publish whose response body is not a JSON object now throws an `AblyException`; 1.x ignored the body.
Expand All @@ -21,9 +21,15 @@ Ably Pub/Sub 2.0 splits the SDK into device-side and server-side packages so tha

- Update, delete and append messages by serial: `UpdateMessageAsync`, `DeleteMessageAsync` and `AppendMessageAsync` on `IHttpChannel` (RSL15, returning `Task<UpdateDeleteResult>`) and on `IRealtimeChannel` (RTL32, returning `Task<Result<UpdateDeleteResult>>`), with the new `MessageOperation` and `UpdateDeleteResult` types.
- Retrieve a message by serial and all of its versions (`GetMessageAsync`, `GetMessageVersionsAsync`), and publish, delete, retrieve and subscribe to message annotations (`Annotations`). `Message` gains `Serial`, `Version`, `Action` and `Annotations`.
- `AnnotationsRequestParams.ExtraParameters` sends any query parameter with `Annotations.GetAsync` (RSAN3a, RSAN3b), alongside the typed `Limit`.
- Declares wire protocol version 6 (was 2): the realtime `v` transport param and the REST `X-Ably-Version` header now send `6` (CSV2). The `/stats` endpoint stays pinned to the v2 response format.
- `Request()`/`RequestV2()` callers that send no `X-Ably-Version` header now receive protocol-6 response shapes (for example, `GET /channels` returns channel names, and a batch `POST /messages` returns a single results envelope). Pass the `X-Ably-Version` header to pin a version.

**Behaviour changes**

- A realtime `PublishAsync` refused by the channel or connection state (RTL6c4: the channel is SUSPENDED or FAILED, or the connection is neither CONNECTED nor allowed to queue the message) now faults with an `AblyException` carrying the refusal's own `ErrorInfo` (code 40000, status 400), as `UpdateMessageAsync`, `DeleteMessageAsync`, `AppendMessageAsync` and `Annotations.PublishAsync` do. 1.x wrapped it in a generic 50000 "Unexpected error" and kept the original error only as the `InnerException`. Any other `AblyException` thrown inside the realtime task methods (`AttachAsync`, `DetachAsync`, `SetOptionsAsync`, the presence methods, `PingAsync`) is passed through the same way; an exception that is not an `AblyException` is still wrapped as 50000. The refusal message for a connection that can't queue now names the connection state rather than the channel state.
- `AnnotationsRequestParams` with a `Limit` outside 0 to 1000 now throws an `AblyException` (code 40003, status 400), like the other argument checks in `Annotations`, rather than an `ArgumentException`.

**Notes**

- Device push-receive (push activation on Android/iOS) is not part of the 2.0 packages; a `net8.0-android`/`net8.0-ios` port on `Ably.PubSub.Device` is a possible future follow-up (currently **parked** — no committed milestone). Push administration (from a backend) is unaffected. See [PushNotifications.md](PushNotifications.md).
Expand Down
Loading
Loading