Skip to content
Draft
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
6 changes: 3 additions & 3 deletions .claude/skills/openapi-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,9 @@ packages/stream_chat/lib/src/
```

- **The role decides the folder.** An envelope goes in `response/` (`CreateUserGroupResponse`). A type the caller
passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, and also
`PartialUpdateUserRequest`, `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other
model stays in `models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`.
passes in to shape a request goes in `request/`, whatever its name: `MessageDelivery`, `UpdateUserPartialRequest`,
and also `PaginationParams` and `ThreadOptions` when their groups migrate them. Every other model stays in
`models/` (`UserGroup`). Copy `lib/src/core/models/request/message_delivery.dart`.
- **Models and envelopes** are `@freezed` classes with a const constructor and `@override final` fields. Copy
`lib/src/core/models/user_group.dart` and `lib/src/core/models/response/create_user_group_response.dart`.
Envelopes carry `required this.duration`, documented with an example value such as `4.21ms`, plus one field per
Expand Down
58 changes: 58 additions & 0 deletions migrations/v11-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ onto Stream's OpenAPI-generated API client.
- [Read Receipts](#read-receipts)
- [Unread Counts](#unread-counts)
- [User Blocking](#user-blocking)
- [User Updates](#user-updates)
- [Channel and Member Custom Data](#channel-and-member-custom-data)
- [Migration Checklist](#migration-checklist)
- [For AI Agents](#for-ai-agents)
Expand Down Expand Up @@ -91,6 +92,7 @@ from the spec, so don't subclass them or depend on their private constructors.
| [**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 |
| [**User Updates**](#user-updates) | `updateUser` and `updateUsers` return a `Result<UpdateUsersResponse>` instead of throwing; `partialUpdateUser(s)` becomes `updateUserPartial` / `updateUsersPartial` and `PartialUpdateUserRequest` becomes `UpdateUserPartialRequest`; `updateUser` no longer sends `role`, `teams` or `teamsRole`, and the returned users no longer carry their private fields in `extraData` |
| [**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 @@ -303,6 +305,19 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed`
| `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 |
| `StreamChatClient.updateUser` / `updateUsers` → `Future<UpdateUsersResponse>` | `Future<Result<UpdateUsersResponse>>` | `retyped` | Returns a `Result` instead of throwing |
| `StreamChatClient.partialUpdateUser` | `StreamChatClient.updateUserPartial` | `renamed` | Same arguments |
| `StreamChatClient.partialUpdateUser` → `Future<UpdateUsersResponse>` | `updateUserPartial` → `Future<Result<UpdateUsersResponse>>` | `retyped` | Returns a `Result` instead of throwing |
| `StreamChatClient.partialUpdateUsers` | `StreamChatClient.updateUsersPartial` | `renamed` | Takes `List<UpdateUserPartialRequest>` |
| `StreamChatClient.partialUpdateUsers` → `Future<UpdateUsersResponse>` | `updateUsersPartial` → `Future<Result<UpdateUsersResponse>>` | `retyped` | Returns a `Result` instead of throwing |
| `PartialUpdateUserRequest` | `UpdateUserPartialRequest` | `renamed` | Same fields |
| `PartialUpdateUserRequest.toJson` | — | `removed` | `UpdateUserPartialRequest` is a plain class |
| `UpdateUsersResponse.fromJson` | — | `removed` | The response is a plain class; construct it directly |
| `PartialUpdateUserRequest.props` | — | `removed` | No longer an `Equatable`; still compares by value, and gains `copyWith` |
| `UpdateUsersResponse()..users = …` | `UpdateUsersResponse(duration: …, users: …)` | `retyped` | A plain class with a const constructor and final fields |
| `UpdateUsersResponse` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal |
| `UpdateUsersResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` |
| `StreamChatApi.user.updateUsers` / `partialUpdateUsers` | `StreamChatClient.updateUsers` / `updateUsersPartial` | `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 @@ -966,6 +981,49 @@ and gain `copyWith`, and their `duration` is always present.
> pagination. The server always sends every `UserBlock` field, so the model no longer makes callers handle nulls
> that never arrive.

### User Updates

**`updateUser` and `updateUsers` return a `Result<UpdateUsersResponse>` instead of throwing, and
`partialUpdateUser` and `partialUpdateUsers` are renamed `updateUserPartial` and `updateUsersPartial`.**
`updateUserPartial` keeps its arguments; `updateUsersPartial` takes `UpdateUserPartialRequest`, renamed from
`PartialUpdateUserRequest`. A `try`/`catch` around them still compiles, but no longer catches a failed call: read
the returned `Result` instead. None of them changes `currentUser`; the `user.updated` event still does.

```dart
// v10
try {
await client.partialUpdateUser(userId, set: {'favorite_color': 'green'});
} on StreamChatException catch (e) {
report(e);
}

// v11
final result = await client.updateUserPartial(userId, set: {'favorite_color': 'green'});
if (result case Failure(:final error)) report(error);
```

**`updateUser` sends the user's name, image, language, visibility and custom data, and nothing else.** v10 sent
the whole user, so the user's online status, ban and timestamps, and an own user's devices, mutes and unread counts,
were stored as custom data on every call; that no longer happens. `role`, `teams` and `teamsRole` are no longer
sent, so these calls cannot change them.

**The returned users no longer carry the fields only they can see in `extraData`.** v10 left every field `User`
does not model in `extraData` as raw JSON; now their devices, mutes, channel mutes, privacy settings, unread counts, blocked
user ids, hidden channels and token revocation time are left out. Read them from `currentUser`. `deactivatedAt`,
`deletedAt` and `shadowBanned` stay in `extraData` and gain typed getters on `User`; each one is filled whenever the
response carries it.

**`UpdateUsersResponse` no longer decodes from JSON.** It is a plain class with a const constructor and final
fields, compares by value and gains `copyWith`, and its `duration` is always present. `UpdateUserPartialRequest`
no longer encodes to JSON or extends `Equatable`; it still compares by value and gains `copyWith`.

**`StreamChatApi.user.updateUsers` and `partialUpdateUsers` are removed.** Call `updateUsers` and
`updateUsersPartial` on `StreamChatClient`.

> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. The partial
> updates take the API's names, as the channel and member partial updates already do. The request carries only
> what the user can change, so nothing of the client's state ends up stored as custom data.

### Moderation

**Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient`
Expand Down
4 changes: 2 additions & 2 deletions openapi-migration/04-roles-guest-and-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
It unwraps `GeneralRepository.createGuest`'s `Result` with `getOrElse`, the one sanctioned unwrap
inside the SDK: a `Result` on a connect call could be ignored and hide a failed sign-in.
- **`createGuest` lives in `GeneralRepository`**, next to `enrichUrl`. Its mappers are in
`lib/src/repository/mapper/user_mapper.dart`, with the `User` mappers they use.
`lib/src/repository/mapper/users_mapper.dart`, with the `User` mappers they use.
- **The envelope is the internal `CreateGuestUserResponse`:** a freezed envelope that only
`createGuest` returns. It replaces v10's public `ConnectGuestUserResponse`, whose only public reach was
`StreamChatApi.guest`, removed together with `GuestApi`.
Expand All @@ -100,7 +100,7 @@
non-date value). v10's flat response shadowed them, so the same guest connected; confirmed live.
- **Not a regression: a custom `ban_expires` that isn't a date** fails the connect in v10 and now alike,
because the socket's `me` lets it through into `OwnUser.fromJson`.
- **`User` is not restructured here.** `user_mapper.dart` maps the generated types onto today's `User`,
- **`User` is not restructured here.** `users_mapper.dart` maps the generated types onto today's `User`,
following [01-foundation](01-foundation.md).

## Risks
Expand Down
22 changes: 9 additions & 13 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, and [19](19-user-blocking.md) blocking 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, [19](19-user-blocking.md) blocking users, and [20](20-user-updates.md) updating users.

**Size:** 5 hand-written method(s) across 1 file(s) → 5 generated operation(s).
**Size:** 3 hand-written method(s) across 1 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.
Expand All @@ -14,8 +14,6 @@
| File | Method | Returns |
| --- | --- | --- |
| `user_api.dart` | `queryUsers` | `QueryUsersResponse` |
| `user_api.dart` | `updateUsers` | `UpdateUsersResponse` |
| `user_api.dart` | `partialUpdateUsers` | `UpdateUsersResponse` |
| `user_api.dart` | `getActiveLiveLocations` | `GetActiveLiveLocationsResponse` |
| `user_api.dart` | `updateLiveLocation` | `Location` |

Expand All @@ -26,22 +24,20 @@
| `GET` | `/api/v2/users/live_locations` | `getUserLiveLocations` | `SharedLocationsResponse` |
| `GET` | `/api/v2/users` | `queryUsers` | `QueryUsersResponse` |
| `PUT` | `/api/v2/users/live_locations` | `updateLiveLocation` | `SharedLocationResponse` |
| `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` |
| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` |

## Decisions to make

- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md).
- `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `users_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md).
- `PrivacySettings` and the push-preference sub-shapes — decide per type.
- **`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, `deactivated_at`, `revoke_tokens_issued_before` and `blocked_user_ids`, which `UserResponse` types and v1 kept in a plain user's `extraData`, become fields or are dropped, never `extraData`. `UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and `UserRequestMapper` stops reading `extraData['name']` and `['image']`.
- `UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated `UserResponse` has no field to fill.
- `updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10's flattened body filed the user's client state (`online`, `banned`, `created_at` and similar) into the stored custom data on every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real users rather than fresh guests, and record it in the CHANGELOG.
- The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 left the key out. For a guest create that made no difference; for an upsert of an existing user, confirm live that a `null` does not reset a stored value differently from an omitted key.
- **`User` promotes its `extraData`-backed values to fields** (README rule 9). `name` and `image`, which `UserResponse` types, and `deactivatedAt`, `deletedAt` and `shadowBanned`, which [20](20-user-updates.md) added as getters over `extraData`, become fields, and the constructor stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `revoke_tokens_issued_before` and `blocked_user_ids`, which v1 kept in a plain user's `extraData`, become fields or are dropped, never `extraData`. The user mappers then copy `custom` verbatim into the field, renamed `custom`, with no `_shadowedCustomKeys`, and `UserRequestMapper` stops reading `extraData['name']` and `['image']`. When promoting: decide each field's `merge` rule (`OwnUser.merge` takes the other user's `extraData` whole today), make `toJson` leave out custom keys named like a field, add persistence columns, and point `UserFilterField.shadowBanned` at the field.
- `UserFilterField.bypassModeration` reads `extraData`, which no generated user has a field to fill.
- `queryUsers` answers `FullUserResponse`, which [20](20-user-updates.md) already maps to `User`; the caller's own entry could map to `OwnUser` once `Mute`, `ChannelMute` and `PrivacySettings` have response mappers.

## Risks

- Every other group depends on the `User` decision.
- User data arrives over the WebSocket on nearly every event.
- `ConnectUserDetails.fromOwnUser` flattens the whole `extraData` into the connect payload, so the `deactivated_at`, `deleted_at` and `shadow_banned` entries the [20](20-user-updates.md) getters read go back to the server as custom data (v10 already did this for `shadow_banned`). Strip the user's own keys there when the WebSocket moves to v2.
- Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left behind. Adding them is additive for anyone reading the response, so revisit them here rather than leaving them dropped for good.
- Until then, a user mapped from `UserResponse` and the same user decoded from v1 JSON carry different `extraData`: the v1 path keeps `blocked_user_ids`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, the mapper drops them. Equality includes `extraData`, so a `Poll` from a REST call (its `createdBy`, its votes' `user`) and the same poll from an event compare unequal.

Expand All @@ -65,8 +61,8 @@
name states its subject and behaviour.
- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from
the table in `README.md`.
- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is
- [ ] `users_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is
gone.
- [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal.
- [ ] `user_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire's `custom`, verbatim, and
- [ ] `users_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire's `custom`, verbatim, and
no typed field is written into it.
4 changes: 2 additions & 2 deletions openapi-migration/11-channels-and-members.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
- **The generated channel and member types map onto today's classes.**
`lib/src/repository/mapper/channels_mapper.dart` maps `ChannelResponse`, `ChannelConfigWithInfo`,
`ChannelMemberResponse`, `Command` and `ChatPreferences` onto the json_serializable `ChannelModel`,
`ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `user_mapper.dart` does for
`ChannelConfig`, `Member`, `Command` and `ChatPreferences`, the way `users_mapper.dart` does for
`User`. [15](15-partial-updates.md) uses it first; groups 06, 07 and 14 will reuse it. This group
restructures those classes and re-points the mapper.
- **Every field `ChannelResponse` and `ChannelMemberResponse` declare is reachable on ours,** as a
Expand All @@ -71,7 +71,7 @@
`createdAt` / `updatedAt`, which ours do not model.
- **The mapper copies `custom` verbatim** (README rule 9). A mapped channel or member still differs from
a v1 decode in one way, for group 09 to settle: `truncatedBy`, `createdBy` and each member's user go
through `user_mapper.dart`, which drops the user fields `User` does not model.
through `users_mapper.dart`, which drops the user fields `User` does not model.
- **The mapper is tested through the client,** by [15](15-partial-updates.md)'s
`updateChannelPartial` tests: the first call that returns a channel through the generated client.

Expand Down
2 changes: 1 addition & 1 deletion openapi-migration/19-user-blocking.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 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.
**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 `users_mapper.dart`, and nothing persists them.

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

Expand Down
Loading
Loading