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
7 changes: 3 additions & 4 deletions .claude/skills/openapi-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +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: `PartialUpdateUserRequest`, and also
`PaginationParams` and `ThreadOptions`. Every other model stays in `models/`
(`UserGroup`). No `request/` type exists yet: the first arrives when a group migrates one out of
`lib/src/core/api/requests.dart`.
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`.
- **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
64 changes: 64 additions & 0 deletions migrations/v11-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ onto Stream's OpenAPI-generated API client.
- [Link Previews](#link-previews)
- [Partial Updates](#partial-updates)
- [Channel Lifecycle](#channel-lifecycle)
- [Read Receipts](#read-receipts)
- [Migration Checklist](#migration-checklist)
- [For AI Agents](#for-ai-agents)
- [Contributing to this guide](#contributing-to-this-guide)
Expand Down Expand Up @@ -82,6 +83,7 @@ from the spec, so don't subclass them or depend on their private constructors.
| [**File Upload**](#file-upload) | Uploads return a `Result<UploadedFile>` and deletes a `Result<void>` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` |
| [**Partial Updates**](#partial-updates) | Channel and member partial updates β€” `updatePartial`, `updateName`, `updateImage`, slow mode, pin and archive β€” return a `Result` instead of throwing; their responses take the API's names, `UpdateChannelPartialResponse` and `UpdateMemberPartialResponse`, and `partialMemberUpdate` becomes `updateMemberPartial` |
| [**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` |
| _(filled in per feature as PRs land)_ | |

---
Expand Down Expand Up @@ -244,6 +246,15 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed`
| `Channel.hide` / `show` / `delete` β†’ `Future<EmptyResponse>` | `Future<Result<HideChannelResponse>>` / `Future<Result<ShowChannelResponse>>` / `Future<Result<DeleteChannelResponse>>` | `retyped` | Returns a `Result` instead of throwing. Calling one before the channel is initialized still throws a `StateError` |
| `StreamChatApi.channel.hideChannel` / `showChannel` / `deleteChannel` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client |
| `MemberUpdatePayload(pinned: true).toJson()` / `MemberUpdateType.pinned.name` | `{'pinned': true}` / `'pinned'` | `removed` | Same for `archived`. Or call `pinChannel` / `unpinChannel` / `archiveChannel` / `unarchiveChannel` |
| `StreamChatClient.markChannelRead` / `markThreadRead` / `markAllRead` β†’ `Future<EmptyResponse>` | `Future<Result<MarkReadResponse>>` | `retyped` | Returns a `Result` instead of throwing |
| `StreamChatClient.markChannelUnread` / `markChannelUnreadByTimestamp` / `markThreadUnread` β†’ `Future<EmptyResponse>` | `Future<Result<void>>` | `retyped` | Returns a `Result` instead of throwing, and carries no value on success |
| `StreamChatClient.markChannelsDelivered` β†’ `Future<EmptyResponse>` | `Future<Result<MarkDeliveredResponse>>` | `retyped` | Returns a `Result` instead of throwing |
| `Channel.markRead` / `markThreadRead` β†’ `Future<EmptyResponse>` | `Future<Result<MarkReadResponse>>` | `retyped` | Returns a `Result` instead of throwing, including when the current user cannot send read events. Calling one before the channel is initialized still throws a `StateError` |
| `Channel.markUnread` / `markUnreadByTimestamp` / `markThreadUnread` β†’ `Future<EmptyResponse>` | `Future<Result<void>>` | `retyped` | Returns a `Result` instead of throwing, including when the current user cannot send read events, and when `markUnread` counts locally and the message is not among the loaded ones. Calling one before the channel is initialized still throws a `StateError` |
| `MarkChannelsDelivered` = `Future<void> Function(Iterable<MessageDelivery>)` | `Future<Result<void>> Function(Iterable<MessageDelivery>)` | `retyped` | The callback `ChannelDeliveryReporter` takes. Return a failure rather than throwing; the reporter keeps the receipts for a later batch |
| `MessageDelivery.toJson` | β€” | `removed` | `MessageDelivery` is a plain class |
| `MessageDelivery` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal |
| `StreamChatApi.channel.markRead` / `markUnread` / `markUnreadByTimestamp` / `markThreadRead` / `markThreadUnread` / `markAllRead` / `markChannelsDelivered` | the `StreamChatClient` methods | `removed` | The endpoints moved to the generated client |
| _(more added per feature as PRs land)_ | | | |

---
Expand Down Expand Up @@ -732,6 +743,59 @@ result.fold(
> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. Each write
> answers its own envelope so a field the API adds later reaches you without another break.

### Read Receipts

**Marking a channel or thread read or unread, and sending delivery receipts, return a `Result` instead of
throwing.** That covers `StreamChatClient.markChannelRead`, `markChannelUnread`, `markChannelUnreadByTimestamp`,
`markThreadRead`, `markThreadUnread`, `markAllRead` and `markChannelsDelivered`, and `Channel.markRead`,
`markUnread`, `markUnreadByTimestamp`, `markThreadRead` and `markThreadUnread`. A `try`/`catch` around one still
compiles, but no longer catches a failed call: read the returned `Result` instead. That includes the
`StreamClientException`s the `Channel` methods raised when the current user cannot send read events in the
channel, and the one `markUnread` raised when it counts unread messages locally and the message is not among the
loaded ones. Both now arrive as failures.

```dart
// v10
try {
await channel.markRead();
} on StreamChatException catch (e) {
report(e);
}

// v11
final result = await channel.markRead();
if (result case Failure(:final error)) report(error);
```

**Marking read answers a `MarkReadResponse`, and sending delivery receipts a `MarkDeliveredResponse`**, where v10
answered `EmptyResponse`. Marking unread carries no value on success.

**`MarkChannelsDelivered` returns a `Result`.** It is the callback `ChannelDeliveryReporter` takes. A custom one
returns a failure rather than throwing, and the reporter keeps the receipts for a later batch:

```dart
// v10
ChannelDeliveryReporter(
onMarkChannelsDelivered: (deliveries) async {
await client.markChannelsDelivered(deliveries);
},
);

// v11
ChannelDeliveryReporter(
onMarkChannelsDelivered: client.markChannelsDelivered,
);
```

**`MessageDelivery` no longer encodes to JSON.** It is a plain class that compares by value and gains `copyWith`.

**`StreamChatApi.channel.markRead`, `markUnread`, `markUnreadByTimestamp`, `markThreadRead`, `markThreadUnread`,
`markAllRead` and `markChannelsDelivered` are removed.** Call them on `StreamChatClient`.

> **Why:** the endpoints moved onto the generated client, which returns a `Result` for every call. Marking read
> and sending receipts answer their own envelope, so a field the API adds later reaches you without another
> break. Marking unread answers nothing the API could extend, so it carries no value.

### Moderation

**Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient`
Expand Down
1 change: 1 addition & 0 deletions openapi-migration/10-messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
- `Attachment`: the generated model defines fields our `extraData` currently absorbs. Decide the promotion rules before writing the mapper.
- Replace the temporary `@DataSerializable` storage codec (`UserGroup`, `UserGroupMember`): decide between dedicated tables and codecs owned by `stream_chat_persistence` before `Message` and `Attachment` become plain models, then delete the typedef and every `fromData`/`toData` it generates.
- `Action` is still the v10 json_serializable class, embedded in `Attachment.actions`, which `Attachment.toJson` writes when sending and `toData` writes to persistence. It becomes a plain model with `Attachment`; group 04 left it alone.
- Expose `MarkReadResponse.event`. [17](17-read-receipts.md) dropped it because its thread carries a `MessageResponse`; the mapper marks it with a TODO.

## Risks

Expand Down
15 changes: 2 additions & 13 deletions openapi-migration/11-channels-and-members.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 11 β€” Channels, Members & Sync

**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. [15](15-partial-updates.md) split the partial channel and member updates out of it, and [16](16-channel-lifecycle.md) hiding, showing and deleting a channel.
**Goal:** The biggest group, and the one every controller above it reads through `ChannelState`. [15](15-partial-updates.md) split the partial channel and member updates out of it, [16](16-channel-lifecycle.md) hiding, showing and deleting a channel, and [17](17-read-receipts.md) the read and delivery receipts.

**Size:** 20 hand-written method(s) across 2 file(s) β†’ 19 generated operation(s).
**Size:** 13 hand-written method(s) across 2 file(s) β†’ 15 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 @@ -15,7 +15,6 @@
| --- | --- | --- |
| `channel_api.dart` | `queryChannel` | `ChannelState` |
| `channel_api.dart` | `queryChannels` | `QueryChannelsResponse` |
| `channel_api.dart` | `markAllRead` | `EmptyResponse` |
| `channel_api.dart` | `updateChannel` | `UpdateChannelResponse` |
| `channel_api.dart` | `acceptChannelInvite` | `AcceptInviteResponse` |
| `channel_api.dart` | `rejectChannelInvite` | `RejectInviteResponse` |
Expand All @@ -24,13 +23,7 @@
| `channel_api.dart` | `removeMembers` | `RemoveMembersResponse` |
| `channel_api.dart` | `sendEvent` | `EmptyResponse` |
| `channel_api.dart` | `truncateChannel` | `EmptyResponse` |
| `channel_api.dart` | `markRead` | `EmptyResponse` |
| `channel_api.dart` | `markUnread` | `EmptyResponse` |
| `channel_api.dart` | `markUnreadByTimestamp` | `EmptyResponse` |
| `channel_api.dart` | `markThreadRead` | `EmptyResponse` |
| `channel_api.dart` | `markThreadUnread` | `EmptyResponse` |
| `channel_api.dart` | `stopWatching` | `EmptyResponse` |
| `channel_api.dart` | `markChannelsDelivered` | `EmptyResponse` |
| `general_api.dart` | `sync` | `SyncResponse` |
| `general_api.dart` | `queryMembers` | `QueryMembersResponse` |

Expand All @@ -45,10 +38,6 @@
| `POST` | `/api/v2/chat/channels/{type}/query` | `getOrCreateDistinctChannel` | `ChannelStateResponse` |
| `GET` | `/api/v2/chat/channels/{type}/{id}/pinned_messages` | `getPinnedMessages` | `GetPinnedMessagesResponse` |
| `POST` | `/api/v2/chat/channels/grouped` | `groupedQueryChannels` | `GroupedQueryChannelsResponse` |
| `POST` | `/api/v2/chat/channels/read` | `markChannelsRead` | `MarkReadResponse` |
| `POST` | `/api/v2/chat/channels/delivered` | `markDelivered` | `MarkDeliveredResponse` |
| `POST` | `/api/v2/chat/channels/{type}/{id}/read` | `markRead` | `MarkReadResponse` |
| `POST` | `/api/v2/chat/channels/{type}/{id}/unread` | `markUnread` | `DurationResponse` |
| `POST` | `/api/v2/chat/channels` | `queryChannels` | `QueryChannelsResponse` |
| `GET` | `/api/v2/chat/members` | `queryMembers` | `MembersResponse` |
| `POST` | `/api/v2/chat/channels/{type}/{id}/event` | `sendEvent` | `EventResponse` |
Expand Down
8 changes: 4 additions & 4 deletions openapi-migration/16-channel-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@
- **`StreamChannelListController.deleteChannel` returns the `Result`,** as `muteChannel` does.
- **`hard_delete` is not exposed.** The server refuses it from a client-side token, and v10 never sent it.
- **Show sends no body,** where v1 sent `{}`; the server accepts both. Hide still sends `clear_history`.
- **The read and delivery receipts stay in 11 for now.** `markRead` and its siblings share endpoints
with the thread read calls, fabricate a response on the local-unread path, and feed
`MessageListUnreadController` and the public `ChannelDeliveryReporter`, which rely on a throw; they
move in a slice of their own.
- **The read and delivery receipts are left to their own slice,
[17](17-read-receipts.md).** `markRead` and its siblings share endpoints with the thread read calls,
fabricate a response on the local-unread path, and feed `MessageListUnreadController` and the public
`ChannelDeliveryReporter`, which relied on a throw.

## Risks

Expand Down
73 changes: 73 additions & 0 deletions openapi-migration/17-read-receipts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 17 β€” Read Receipts

**Goal:** Move marking channels and threads read and unread, and the delivery receipts, ahead of [11](11-channels-and-members.md): they answer only a `duration` and a read event, and the event can wait for [10](10-messages.md).

**Size:** 0 hand-written method(s) across 0 file(s) β†’ 4 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/chat/channels/read` | `markChannelsRead` | `MarkReadResponse` |
| `POST` | `/api/v2/chat/channels/delivered` | `markDelivered` | `MarkDeliveredResponse` |
| `POST` | `/api/v2/chat/channels/{type}/{id}/read` | `markRead` | `MarkReadResponse` |
| `POST` | `/api/v2/chat/channels/{type}/{id}/unread` | `markUnread` | `DurationResponse` |

## Decisions taken

- **Split out of [11](11-channels-and-members.md), ahead of it.** None of the calls reads anything into
client state β€” the `message.read`, `notification.mark_unread` and `message.delivered` events do that.
- **The v2 routes are the v1 handlers.** `lib/chat/routes.go` mounts all four in the shared
`coreRoutes`; none is gated, in beta or deprecated. A live run answered the same on v1 and v2 for
every call, with the generated requests' explicit `null`s.
- **Moved off `ChannelApi`:** `markRead`, `markUnread`, `markUnreadByTimestamp`, `markThreadRead`,
`markThreadUnread`, `markAllRead` and `markChannelsDelivered`, each now a `StreamChatClient` method
over `ChannelsRepository`. The v10 names stay: seven calls share four operations, so the spec's
names cannot tell most of them apart.
- **Marking read answers `MarkReadResponse`, carrying only `duration`.** The spec's `event` holds a
`ThreadResponse` whose `parentMessage` is a `MessageResponse`, which needs group 10's mappers.
Exposing it later is additive; the mapper marks it with a TODO.
- **Marking unread answers `DurationResponse`, so it returns `Result<void>`.** The delivery receipts
answer `MarkDeliveredResponse`.
- **`markAllRead` sends an empty `MarkChannelsReadRequest`.** `read_by_channel: null` marks every channel
read, as v1's `{}` did. The handler refuses a request with no body (400 "invalid json data"), but
the generated operation always sends one: it writes `{}` when given no request.
- **`Channel`'s guards return a failure.** A missing read-events capability, and on the local-unread
path a message `markUnread` cannot find, were thrown `StreamClientException`s; they are failures
now. An uninitialized channel still throws a `StateError`.
- **The local-unread path answers `MarkReadResponse(duration: '0ms')`** from `Channel.markRead`, where
v10 built an empty `EmptyResponse`, so it keeps the type of the remote path.
- **`MarkChannelsDelivered` returns `Future<Result<void>>`.** `ChannelDeliveryReporter` relied on a
throw to keep the receipts of a failed send; it now reads the failure. Changing the public typedef
is a break, approved for this group.
- **`MessageDelivery` moves to `models/request/`** as a freezed class without `toJson`.

## 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.
9 changes: 6 additions & 3 deletions openapi-migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ generated operations in scope, the decisions that group has to make, its risks,
| [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | β˜‘ |
| [09](09-users.md) | Users | 9 | 9 | ☐ |
| [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ |
| [11](11-channels-and-members.md) | Channels, Members & Sync | 20 | 19 | ☐ |
| [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ |
| [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | β˜‘ |
| [13](13-push-preferences.md) | Push Preferences | 1 | 1 | ☐ |
| [14](14-banned-users.md) | Banned Users β€” split out of 08 | 1 | 1 | ☐ |
| [15](15-partial-updates.md) | Partial Updates β€” split out of 11 | 0 | 2 | β˜‘ |
| [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 | β˜‘ |

**Coverage:** 77 hand-written methods across 10 files, and all 129 generated operations, each claimed by exactly
**Coverage:** 70 hand-written methods across 10 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 @@ -172,7 +173,9 @@ surfaces before it reaches `Message` and `ChannelState`:
- **15** is the partial channel and member updates, split out of 11 so `channel_mapper.dart` lands on calls that
read nothing into `ChannelState`. It landed first; the full update waits in 11 for group 10's message mappers.
- **16** is hiding, showing and deleting a channel, split out of 11 after 15 because they answer nothing
`channel_mapper.dart` cannot map; the read and delivery receipts stay in 11 for a slice of their own.
`channel_mapper.dart` cannot map.
- **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.
- **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