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
13 changes: 6 additions & 7 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 Expand Up @@ -346,10 +346,9 @@ Three data-shape traps while mapping:
- **The model's `custom` is the wire's `custom`, verbatim** (README rule 9). A migrated model names it `custom`
— `extraData` on one whose group has not run — and the mapper copies the generated map into it and sends it
back as `custom`; never filter either. A typed wire field our model lacks is added as a field, with
approval, or left out — never parked in `extraData` behind a getter, as `User.name` still is until group 09
promotes it. A v1 decoder splits flat JSON by a key list — `topLevelFields` while the model still has JSON, a
private list beside the converter once it does not — and a v1 writer leaves a custom entry named like a key out
so it cannot overwrite the key.
approval, or left out — never parked in `extraData` behind a getter. A v1 decoder splits flat JSON by a key
list — `topLevelFields` while the model still has JSON, a private list beside the converter once it does not —
and a v1 writer leaves a custom entry named like a key out so it cannot overwrite the key.
- **Units on anything that takes a `Duration`.** The wire carries a bare integer and the field name rarely says
in what. Read the Go payload type: `commonpayloads.DurationInMinutes` and `DurationMilliseconds` sit on sibling
endpoints in the same feature — `ban.timeout` and `mute.timeout` are minutes, `muteChannel.expiration` is
Expand Down
2 changes: 1 addition & 1 deletion docs/docs_screenshots/test/reactions/reactions_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ void main() {
if (user == null) return const SizedBox.shrink();
return StreamListTile(
leading: StreamUserAvatar(size: StreamAvatarSize.md, user: user),
title: Text(user.name),
title: Text(user.nameOrId),
trailing: StreamEmoji(
size: StreamEmojiSize.md,
emoji: StreamChatConfiguration.of(context).reactionIconResolver.resolve(reaction.type),
Expand Down
126 changes: 124 additions & 2 deletions migrations/v11-migration.md

Large diffs are not rendered by default.

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
23 changes: 9 additions & 14 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,24 +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).
- `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` 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.
- `UserFilterField.bypassModeration` reads `custom`, which no generated user has a field to fill.
- **`User` and `OwnUser` move to freezed without changing the current user's merge rules.** freezed's `copyWith` clears a field passed `null` where today's keeps it, and it can't generate one for `OwnUser` while it takes `User`'s fields through super-parameters. `OwnUser.merge` builds through the constructor and states each field's rule (keep a nullable value the other user leaves out, except `deactivatedAt`, `deletedAt` and `shadowBanned`, taken as they are, and `banExpires`, always kept); keep those rules in a dedicated merge, and run `copywith_audit.py` on both models for every other call site, such as the `user.updated` handler's preserve list.
- `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.
- A user mapped from `UserResponse` and the same user decoded from v1 JSON compare unequal: the v1 decode keeps `invisible`, `shadowBanned` and `banExpires`, which `UserResponse` does not carry.
- 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` and `deactivated_at`, 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.

## Definition of done

Expand All @@ -65,8 +60,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
- [x] `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 @@ -61,7 +61,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 @@ -73,7 +73,7 @@
`Command`'s `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 two ways. `truncatedBy`, `createdBy` and each member's user go through
`user_mapper.dart`, which drops the user fields `User` does not model, for group 09 to settle. And
`users_mapper.dart`, which drops the user fields `User` does not model, for group 09 to settle. And
a v1 member keeps the wire's `role` in `custom`, where the mapper leaves the typed `role` out.
- **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
92 changes: 92 additions & 0 deletions openapi-migration/20-user-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 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). An `OwnUser` decoded from the connection keeps `unread_count`,
`total_unread_count_by_team` and `latest_hidden_channels` in `custom`, so an upsert of it still
stores them as custom data, as v10 did. `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` and `OwnUser` promote the fields v10 parked in `extraData`, and rename it `custom`**
(README rule 9), since these calls are the first to map a user. `User.name`, `image`,
`deactivatedAt`, `deletedAt` and `shadowBanned` are fields with persistence columns;
`blockedUserIds` moves up from `OwnUser`. `OwnUser` doesn't model `unread_count`,
`total_unread_count_by_team` or `latest_hidden_channels`, which no event keeps current. The mappers
leave the keys the SDK doesn't model out; a v1 user keeps them in `custom`, as a v1 member keeps
`role`.
Each generated user mapper fills the ones its response carries (`UserResponse` has no
`shadowBanned`).
- **`User.name` is nullable and has no fallback to `id`;** `User.nameOrId` has it, for a missing or
empty name, as v10's `name` did. Name and image are stored as given. The UI names a user by
`nameOrId` everywhere except avatar initials, which show the placeholder icon for an unnamed user,
and filters and sorts, which read the raw `name`; the avatar treats an empty image as none.
- **`custom` is the wire's, verbatim,** and the v1 writers leave out a custom entry named like a key,
so it cannot overwrite the field. `OwnUser.fromUser` copies the user's fields and no longer reads an
own user's fields out of the custom data.
- **`OwnUser.merge` takes the other user's fields,** as before. A `user.updated` event's user is decoded
again as an `OwnUser`, so the privacy settings it carries for the current user replace theirs, as in
v10 — a temporary adapter (README) for the WebSocket v2 move to replace.

## Risks

- A `try`/`catch` around `updateUser`, `updateUsers`, `updateUserPartial` or `updateUsersPartial` still compiles but no longer catches a failed call.
- A string interpolation of `User.name` still compiles now that it is nullable, and prints `null` for a user with no name; `nameOrId` gives the old output.

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