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 @@ -253,10 +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: `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
66 changes: 66 additions & 0 deletions migrations/v11-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ onto Stream's OpenAPI-generated API client.
- [Link Previews](#link-previews)
- [Partial Updates](#partial-updates)
- [Channel Lifecycle](#channel-lifecycle)
- [Read Receipts](#read-receipts)
- [Channel and Member Custom Data](#channel-and-member-custom-data)
- [Migration Checklist](#migration-checklist)
- [For AI Agents](#for-ai-agents)
Expand Down Expand Up @@ -85,6 +86,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` |
| [**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 @@ -268,6 +270,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 |
| `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 @@ -803,6 +814,59 @@ stays in `custom` and leaves the field alone; `toJson` leaves it out, so it cann
> **Why:** the API calls it `custom`, and every migrated model takes that name. The flags arrive as fields of their
> own, so keeping them in the custom data let a custom entry with the same name overwrite them.

### 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 Expand Up @@ -1088,6 +1152,8 @@ Work top to bottom; each item is independently verifiable.
- [ ] Apply every row of the [Symbol Map](#symbol-map)
- [ ] Re-check custom data access: fields that used to arrive in `extraData` may now be typed properties
- [ ] If you implement `AttachmentFileUploader`, return a `Result` from it — see [File Upload](#file-upload)
- [ ] If you pass a custom `onMarkChannelsDelivered` to `ChannelDeliveryReporter`, return a `Result` from it — see
[Read Receipts](#read-receipts)
- [ ] If you persist models yourself, re-check nullability as endpoints move to the generated types, which are nullable wherever the API allows it
- [ ] Replace `sort: null` on any list controller with `XSort.empty` if you relied on server ordering — see
[Sorting](#sorting)
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
75 changes: 75 additions & 0 deletions openapi-migration/17-read-receipts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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] `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