Skip to content
57 changes: 57 additions & 0 deletions migrations/v11-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ onto Stream's OpenAPI-generated API client.
- [Channel Lifecycle](#channel-lifecycle)
- [Read Receipts](#read-receipts)
- [Unread Counts](#unread-counts)
- [User Blocking](#user-blocking)
- [Channel and Member Custom Data](#channel-and-member-custom-data)
- [Migration Checklist](#migration-checklist)
- [For AI Agents](#for-ai-agents)
Expand Down Expand Up @@ -89,6 +90,7 @@ from the spec, so don't subclass them or depend on their private constructors.
| [**Channel Lifecycle**](#channel-lifecycle) | Hiding, showing and deleting a channel return a `Result` instead of throwing; stopping watching still throws |
| [**Read Receipts**](#read-receipts) | Marking read, unread and delivered return a `Result` instead of throwing; `ChannelDeliveryReporter`'s callback returns a `Result` |
| [**Unread Counts**](#unread-counts) | `getUnreadCount` returns a `Result<GetUnreadCountResponse>` instead of throwing; the response and its `UnreadCounts*` models no longer decode JSON and compare by value |
| [**User Blocking**](#user-blocking) | `blockUser`, `unblockUser` and `getBlockedUsers` (was `queryBlockedUsers`) return a `Result` instead of throwing; their responses are renamed `BlockUsersResponse` and `GetBlockedUsersResponse`, `unblockUser` answers a new `UnblockUsersResponse`, and `UserBlock`'s fields are all non-nullable |
| [**Channel and Member Custom Data**](#channel-and-member-custom-data) | `extraData` is `custom` on `ChannelModel`, `Member` and `Channel`, and holds the custom data verbatim; the channel and member flags it carried are fields |
| _(filled in per feature as PRs land)_ | |

Expand Down Expand Up @@ -287,6 +289,20 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed`
| `GetUnreadCountResponse`, `UnreadCountsChannel`, `UnreadCountsThread`, `UnreadCountsChannelType` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal |
| `GetUnreadCountResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` |
| `StreamChatApi.user.getUnreadCount` | `StreamChatClient.getUnreadCount` | `removed` | The endpoint moved to the generated client |
| `StreamChatClient.blockUser` → `Future<UserBlockResponse>` | `Future<Result<BlockUsersResponse>>` | `retyped` | Returns a `Result` instead of throwing. The blocked user is still added to `OwnUser.blockedUserIds` on success |
| `StreamChatClient.unblockUser` → `Future<EmptyResponse>` | `Future<Result<UnblockUsersResponse>>` | `retyped` | Returns a `Result` instead of throwing. The user is still removed from `OwnUser.blockedUserIds` on success |
| `StreamChatClient.queryBlockedUsers` | `StreamChatClient.getBlockedUsers` | `renamed` | Same call, the API's name |
| `StreamChatClient.queryBlockedUsers` → `Future<BlockedUsersResponse>` | `Future<Result<GetBlockedUsersResponse>>` | `retyped` | Returns a `Result` instead of throwing. `OwnUser.blockedUserIds` is still replaced on success |
| `UserBlockResponse` | `BlockUsersResponse` | `renamed` | Same fields |
| `BlockedUsersResponse` | `GetBlockedUsersResponse` | `renamed` | Same fields |
| `UserBlockResponse.fromJson`, `BlockedUsersResponse.fromJson`, `UserBlock.fromJson` / `toJson` | — | `removed` | The responses and model are plain classes; construct them directly |
| `UserBlockResponse()..blockedUserId = …`, `BlockedUsersResponse()..blocks = …` and their other setters | `BlockUsersResponse(duration: …, blockedUserId: …, …)`, `GetBlockedUsersResponse(duration: …, blocks: …)` | `retyped` | Plain classes with a const constructor and final fields |
| `UserBlockResponse`, `BlockedUsersResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal |
| `UserBlockResponse.duration`, `BlockedUsersResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` |
| `UserBlock extends Equatable`, `UserBlock.props` | `UserBlock` (value `==`, `copyWith`) | `removed` | Equality is unchanged; `props` is gone and `UserBlock` is no longer an `Equatable` |
| `UserBlock.blockedUser` (`User?`), `.userId` / `.blockedUserId` (`String?`), `.createdAt` (`DateTime?`) | `User`, `String`, `DateTime` — required in the constructor | `retyped` | The server always sends them; drop any `!`, `?.` or `?? …` |
| `UserBlock.copyWith(user: User?, …)`, keeping a field when passed `null` | `copyWith(user: User, …)` | `retyped` | Every argument is non-nullable, so a call that passed a nullable value no longer compiles; pass the value or leave the argument out |
| `StreamChatApi.user.blockUser` / `unblockUser` / `queryBlockedUsers` | `StreamChatClient.blockUser` / `unblockUser` / `getBlockedUsers` | `removed` | The endpoints moved to the generated client |
| `ChannelModel.extraData` / `Member.extraData` | `ChannelModel.custom` / `Member.custom` | `renamed` | The wire's name for it; also the constructor and `copyWith` arguments |
| `Channel.extraData` / `Channel.extraDataStream` / `Channel(…, extraData:)` / `StreamChatClient.channel(…, extraData:)` | `Channel.custom` / `Channel.customStream` / `custom:` | `renamed` | Follows `ChannelModel.custom`; the setter is renamed too |
| `channel.extraData['disabled']` / `['hidden']` / `['muted']` / `['blocked']` / `['truncated_at']` / `['truncated_by']` / `['auto_translation_enabled']` / `['auto_translation_language']` | `ChannelModel.disabled` / `hidden` / `muted` / `blocked` / `truncatedAt` / `truncatedBy` / `autoTranslationEnabled` / `autoTranslationLanguage` | `retyped` | Fields now, not custom data; `copyWith(custom:)` no longer sets them |
Expand Down Expand Up @@ -909,6 +925,47 @@ always present.
> **Why:** the endpoint moved onto the generated client, which returns a `Result` for every call. The response
> keeps its v10 name and fields; what changes is the error handling, the JSON codecs and value equality.

### User Blocking

**`blockUser`, `unblockUser` and `getBlockedUsers` return a `Result` instead of throwing, and `queryBlockedUsers`
is renamed `getBlockedUsers`.** They answer `BlockUsersResponse` (renamed from `UserBlockResponse`),
`UnblockUsersResponse` (new; `unblockUser` returned an `EmptyResponse`) and `GetBlockedUsersResponse` (renamed from
`BlockedUsersResponse`). A `try`/`catch` around them still compiles, but no longer catches a failed call: read the
returned `Result` instead. On success they still update the current user's `blockedUserIds`; a failure leaves it as
it was.

```dart
// v10
try {
final response = await client.queryBlockedUsers();
showBlocked(response.blocks.map((block) => block.blockedUser!));
} on StreamChatException catch (e) {
report(e);
}

// v11
final result = await client.getBlockedUsers();
switch (result) {
case Success(:final data): showBlocked(data.blocks.map((block) => block.blockedUser));
case Failure(:final error): report(error);
}
```

**`UserBlock.blockedUser`, `userId`, `blockedUserId` and `createdAt` are non-nullable** and required in the
constructor; drop any `!`, `?.` or `?? …` on them. `UserBlock` no longer decodes from or encodes to JSON and no
longer extends `Equatable`; it still compares by value and keeps `copyWith`. `BlockUsersResponse` and
`GetBlockedUsersResponse` no longer decode from JSON, are plain classes with a const constructor, compare by value
and gain `copyWith`, and their `duration` is always present.

**`StreamChatApi.user.blockUser`, `unblockUser` and `queryBlockedUsers` are removed.** Call `blockUser`,
`unblockUser` and `getBlockedUsers` on `StreamChatClient`.

> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. `blockUser` and
> `unblockUser` keep their names, because each takes one user id, and answer the API's responses under the API's
> names. `queryBlockedUsers` becomes `getBlockedUsers`, the API's name, because the call takes no filter, sort or
> pagination. The server always sends every `UserBlock` field, so the model no longer makes callers handle nulls
> that never arrive.

### Moderation

**Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient`
Expand Down
10 changes: 2 additions & 8 deletions openapi-migration/09-users.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 09 — Users

**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most. [18](18-unread-counts.md) split the current user's unread counts out of it.
**Goal:** `User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the most. [18](18-unread-counts.md) split the current user's unread counts out of it, and [19](19-user-blocking.md) blocking users.

**Size:** 8 hand-written method(s) across 1 file(s) → 8 generated operation(s).
**Size:** 5 hand-written method(s) across 1 file(s) → 5 generated operation(s).

> This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included.
> Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run.
Expand All @@ -16,21 +16,15 @@
| `user_api.dart` | `queryUsers` | `QueryUsersResponse` |
| `user_api.dart` | `updateUsers` | `UpdateUsersResponse` |
| `user_api.dart` | `partialUpdateUsers` | `UpdateUsersResponse` |
| `user_api.dart` | `blockUser` | `UserBlockResponse` |
| `user_api.dart` | `unblockUser` | `EmptyResponse` |
| `user_api.dart` | `queryBlockedUsers` | `BlockedUsersResponse` |
| `user_api.dart` | `getActiveLiveLocations` | `GetActiveLiveLocationsResponse` |
| `user_api.dart` | `updateLiveLocation` | `Location` |

### Generated operations that cover it

| Verb | Path | Operation | Response |
| --- | --- | --- | --- |
| `POST` | `/api/v2/users/block` | `blockUsers` | `BlockUsersResponse` |
| `GET` | `/api/v2/users/block` | `getBlockedUsers` | `GetBlockedUsersResponse` |
| `GET` | `/api/v2/users/live_locations` | `getUserLiveLocations` | `SharedLocationsResponse` |
| `GET` | `/api/v2/users` | `queryUsers` | `QueryUsersResponse` |
| `POST` | `/api/v2/users/unblock` | `unblockUsers` | `UnblockUsersResponse` |
| `PUT` | `/api/v2/users/live_locations` | `updateLiveLocation` | `SharedLocationResponse` |
| `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` |
| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` |
Expand Down
69 changes: 69 additions & 0 deletions openapi-migration/19-user-blocking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 19 — User Blocking

**Goal:** Move blocking and unblocking a user, and listing the blocked users, ahead of [09](09-users.md): they embed `User` only through the existing `user_mapper.dart`, and nothing persists them.

**Size:** 0 hand-written method(s) across 0 file(s) → 3 generated operation(s).

> This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included.
> Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run.

## Scope

### Hand-written today

| File | Method | Returns |
| --- | --- | --- |

### Generated operations that cover it

| Verb | Path | Operation | Response |
| --- | --- | --- | --- |
| `POST` | `/api/v2/users/block` | `blockUsers` | `BlockUsersResponse` |
| `GET` | `/api/v2/users/block` | `getBlockedUsers` | `GetBlockedUsersResponse` |
| `POST` | `/api/v2/users/unblock` | `unblockUsers` | `UnblockUsersResponse` |

## Decisions taken

- **Split out of [09](09-users.md), ahead of it, as one slice.** Blocking and unblocking are a pair,
and the list refreshes the same `OwnUser.blockedUserIds`. No WebSocket or persisted model embeds
`UserBlock`, so it needs no converter and no storage codec.
- **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts one `userBlockRoutes` table
(`v1.BlockUsers`, `v1.UnblockUsers`, `v1.GetBlockedUsers`) both at the root and under `/api/v2/`;
only the JSON encoding differs. None is gated, in beta or deprecated.
- **Moved off `UserApi`:** `blockUser`, `unblockUser` and `queryBlockedUsers`, now `StreamChatClient`
methods over `UsersRepository`.
- **`blockUser` and `unblockUser` keep their names,** rather than the spec's `blockUsers` and
`unblockUsers`: each takes one user id, and a method that takes several would be added beside them.
- **Their envelopes take the spec's plural names,** `BlockUsersResponse` (renamed from
`UserBlockResponse`) and `UnblockUsersResponse` (new; v10 returned `EmptyResponse`, but the generated
response is a named one). If the API starts blocking several users in one call, these responses gain
fields without a rename, and a batch method can return the same envelope.
- **`queryBlockedUsers` is renamed `getBlockedUsers`,** answering `GetBlockedUsersResponse` (renamed
from `BlockedUsersResponse`), as the spec names them: the call takes no filter, sort or pagination.
- **`UserBlock` is a freezed plain model with every field non-nullable,** following the wire rather
than v10, which typed `blockedUser`, `userId`, `blockedUserId` and `createdAt` nullable. The
server always sends them; the break is approved for this group.
- **`OwnUser.blockedUserIds` is updated only on success,** through `Result.onSuccess`, as v10 did by
throwing before it reached the update. Failures are still logged, through `Result.onFailure`.

## Risks


## Definition of done

- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left
hand-written, with the reason.
- [x] Public methods return `Future<Result<T>>`; no `getOrThrow()` inside the SDK.
- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified.
- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is
handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)).
- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group
persists anything.
- [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break.
- [x] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`.
- [x] Decisions recorded in this file, and the status box ticked in `README.md`.
- [x] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation)
and [`EFFECTIVE_DART_DOC.md`](../EFFECTIVE_DART_DOC.md), including on symbols this group
retyped but whose docs it left alone.
- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each
name states its subject and behaviour.
7 changes: 5 additions & 2 deletions openapi-migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ generated operations in scope, the decisions that group has to make, its risks,
| [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ |
| [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ |
| [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ |
| [09](09-users.md) | Users | 8 | 8 | ☐ |
| [09](09-users.md) | Users | 5 | 5 | ☐ |
| [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ |
| [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ |
| [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ |
Expand All @@ -30,9 +30,10 @@ generated operations in scope, the decisions that group has to make, its risks,
| [16](16-channel-lifecycle.md) | Channel Lifecycle — split out of 11 | 0 | 3 | ☑ |
| [17](17-read-receipts.md) | Read Receipts — split out of 11 | 0 | 4 | ☑ |
| [18](18-unread-counts.md) | Unread Counts — split out of 09 | 0 | 1 | ☑ |
| [19](19-user-blocking.md) | User Blocking — split out of 09 | 0 | 3 | ☑ |
| [21](21-custom-data.md) | Custom Data Check — no model still says `extraData` | — | — | ☐ |

**Coverage:** 56 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly
**Coverage:** 53 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly
one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest).


Expand Down Expand Up @@ -204,6 +205,8 @@ surfaces before it reaches `Message` and `ChannelState`:
- **17** is the read and delivery receipts, split out of 11 after 16. They answer only a `duration` and a read
event, which waits for group 10's message mappers.
- **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it.
- **19** is blocking and unblocking users and listing the blocked ones, split out of 09 because it embeds `User`
only through the existing mappers and nothing persists it.
- **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the
SDK.
- **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the
Expand Down
Loading
Loading