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 @@ -251,9 +251,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 @@ -38,6 +38,7 @@ onto Stream's OpenAPI-generated API client.
- [Read Receipts](#read-receipts)
- [Unread Counts](#unread-counts)
- [User Blocking](#user-blocking)
- [User Updates](#user-updates)
- [Migration Checklist](#migration-checklist)
- [For AI Agents](#for-ai-agents)
- [Contributing to this guide](#contributing-to-this-guide)
Expand Down Expand Up @@ -88,6 +89,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` |
| _(filled in per feature as PRs land)_ | |

---
Expand Down Expand Up @@ -278,6 +280,19 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed`
| `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 `?? …` |
| `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 |
| _(more added per feature as PRs land)_ | | | |

---
Expand Down Expand Up @@ -894,6 +909,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
17 changes: 7 additions & 10 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,21 @@
| `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).
- `PrivacySettings` and the push-preference sub-shapes — decide per type.
- `UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before` out of every user's `extraData` (`_shadowedCustomKeys`), where v1 kept the `OwnUser`-only keys in a plain user's `extraData`. Revisit once the mapper serves plain users.
- `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.
- `UserResponse.toModel()` and `FullUserResponse.toModel()` drop custom data named like one of the user's own fields (`_shadowedCustomKeys`): `deactivated_at`, `deleted_at` and `shadow_banned` are refilled from the typed fields, and the `OwnUser`-only keys and `revoke_tokens_issued_before` are left out, where v1 kept them in a plain user's `extraData`. Revisit once the mapper serves plain users.
- **Whether `User` promotes its `extraData`-backed getters to real fields.** `User.deactivatedAt`, `deletedAt` and `shadowBanned`, added in [20](20-user-updates.md) the way `Member` promoted its fields, arrive as root fields but live in `extraData` and are read back through getters; the constructors write them there. Promoting them is a break: their keys leave `extraData`, a key set in `extraData` no longer sets the field, and a `custom` filter or sort field naming one reads null locally. When promoting: (1) decide each field's `merge` rule (`OwnUser.merge` takes the other user's `extraData` whole today); (2) make `toJson` leave out `extraData` keys named like a field; (3) keep the constructor copying `extraData`; (4) add persistence columns; (5) point `UserFilterField.shadowBanned` at the field. Decide together with [11](11-channels-and-members.md)'s `ChannelModel` and `Member` promotions. `revokeTokensIssuedBefore` stays out of `User`.
- `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.

## Definition of done
Expand Down
74 changes: 74 additions & 0 deletions openapi-migration/20-user-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# 20 — User Updates

**Goal:** Move creating, updating and partially updating users ahead of [09](09-users.md): the response maps onto the current `User`, and nothing persists it.

**Size:** 0 hand-written method(s) across 0 file(s) → 2 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` | `updateUsers` | `UpdateUsersResponse` |
| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` |

## Decisions taken

- **Split out of [09](09-users.md), ahead of it.** `updateUser`, `updateUsers`, `partialUpdateUser` and
`partialUpdateUsers` read nothing into client state, as in v10; the `user.updated` event does.
- **The v2 routes are the v1 handlers.** `lib/core/api/users/routes.go` mounts `UpdateUsers` and
`UpdateUsersPartial` in the common routes, at the root and under `/api/v2/`; only the JSON encoding
differs. Neither is gated, in beta or deprecated.
- **The upsert sends `User.toRequest()`.** v10 sent the whole user flattened, and v1 stored every key
its request does not declare as custom data: the user's `online`, `banned` and timestamps, and an own
user's devices, mutes and unread counts. v2 drops unknown keys, so that stops, as it did for guests
in [04](04-roles-guest-and-app.md). `User.toRequest()` also leaves out the own-user keys an `OwnUser`
decoded from the connection keeps in `extraData` (`unread_count`, `total_unread_count_by_team`,
`latest_hidden_channels`), which v2 would otherwise store as custom data. `role`, `teams` and
`teams_role` are no longer sent: a client-side token never stores them, and a different role was
refused.
- **Moved off `UserApi`:** `updateUsers` and `partialUpdateUsers`, now `StreamChatClient` methods over
`UsersRepository`.
- **Explicit `null`s are not a regression.** The generated request sends `language: null` and
`invisible: null` where v10 left the keys out; the server reads a null or missing language as empty,
and a null or missing `invisible` as unchanged.
- **The partial updates take the API's names,** `updateUserPartial` and `updateUsersPartial`, with
`UpdateUserPartialRequest` (renamed from `PartialUpdateUserRequest`, freezed, without `toJson`), as
the channel and member partial updates already do. `updateUserPartial` keeps v10's arguments.
- **`UpdateUsersResponse` keeps its name** and maps each `FullUserResponse` to a `User`. No public
full-user type: a client-side caller can only update itself, the server blanks the private fields
for anyone else, and the caller's own are on `currentUser`. `membership_deletion_task_id` is always
empty and stays out.
- **`User` gains `deactivatedAt`, `deletedAt` and `shadowBanned`** as getters over `extraData`, the
way `Member` promoted its fields. Each generated user mapper fills the ones its response carries
(`UserResponse` has no `shadowBanned`), so the values v1 left raw in `extraData` are no longer lost;
persistence needs no change.

## 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] `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.
Loading
Loading