Skip to content
Draft
42 changes: 42 additions & 0 deletions migrations/v11-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ onto Stream's OpenAPI-generated API client.
- [Partial Updates](#partial-updates)
- [Channel Lifecycle](#channel-lifecycle)
- [Read Receipts](#read-receipts)
- [Unread Counts](#unread-counts)
- [Channel and Member Custom Data](#channel-and-member-custom-data)
- [Migration Checklist](#migration-checklist)
- [For AI Agents](#for-ai-agents)
Expand Down Expand Up @@ -87,6 +88,7 @@ from the spec, so don't subclass them or depend on their private constructors.
| [**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` |
| [**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 |
| [**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 @@ -279,6 +281,12 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed`
| `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 |
| `StreamChatClient.getUnreadCount` → `Future<GetUnreadCountResponse>` | `Future<Result<GetUnreadCountResponse>>` | `retyped` | Returns a `Result` instead of throwing. The current user's unread counts are still updated on success |
| `GetUnreadCountResponse.fromJson`, `UnreadCountsChannel.fromJson` / `toJson`, `UnreadCountsThread.fromJson` / `toJson`, `UnreadCountsChannelType.fromJson` / `toJson` | — | `removed` | The response and models are plain classes; construct them directly |
| `GetUnreadCountResponse()..totalUnreadCount = …` and its other setters | `GetUnreadCountResponse(duration: …, totalUnreadCount: …, …)` | `retyped` | A plain class with a const constructor and final fields |
| `GetUnreadCountResponse`, `UnreadCountsChannel`, `UnreadCountsThread`, `UnreadCountsChannelType` identity `==` | value `==`, plus `copyWith` | `retyped` | Two instances with the same fields are now equal |
| `GetUnreadCountResponse.duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` |
| `StreamChatApi.user.getUnreadCount` | `StreamChatClient.getUnreadCount` | `removed` | The endpoint 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 @@ -867,6 +875,40 @@ ChannelDeliveryReporter(
> 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.

### Unread Counts

**`StreamChatClient.getUnreadCount` returns a `Result<GetUnreadCountResponse>` instead of throwing.** A
`try`/`catch` around it still compiles, but no longer catches a failed call: read the returned `Result` instead.
On success it still updates the current user's `totalUnreadCount`, `unreadChannels` and `unreadThreads`; a
failure leaves them as they were.

```dart
// v10
try {
final counts = await client.getUnreadCount();
showBadge(counts.totalUnreadCount);
} on StreamChatException catch (e) {
report(e);
}

// v11
final result = await client.getUnreadCount();
switch (result) {
case Success(:final data): showBadge(data.totalUnreadCount);
case Failure(:final error): report(error);
}
```

**`UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` no longer decode from or encode to
JSON, and `GetUnreadCountResponse` no longer decodes from it.** All four compare by value and gain `copyWith`.
`GetUnreadCountResponse` is now a plain class with a const constructor and final fields, and its `duration` is
always present.

**`StreamChatApi.user.getUnreadCount` is removed.** Call `StreamChatClient.getUnreadCount`.

> **Why:** the endpoint moved onto the generated client, which returns a `Result` for every call. The response
> keeps its v10 name and fields; what changes is the error handling, the JSON codecs and value equality.

### Moderation

**Muting, banning and flagging return a `Result` instead of throwing**, on both `StreamChatClient`
Expand Down
6 changes: 2 additions & 4 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.
**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.

**Size:** 9 hand-written method(s) across 1 file(s) → 9 generated operation(s).
**Size:** 8 hand-written method(s) across 1 file(s) → 8 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 @@ -19,7 +19,6 @@
| `user_api.dart` | `blockUser` | `UserBlockResponse` |
| `user_api.dart` | `unblockUser` | `EmptyResponse` |
| `user_api.dart` | `queryBlockedUsers` | `BlockedUsersResponse` |
| `user_api.dart` | `getUnreadCount` | `GetUnreadCountResponse` |
| `user_api.dart` | `getActiveLiveLocations` | `GetActiveLiveLocationsResponse` |
| `user_api.dart` | `updateLiveLocation` | `Location` |

Expand All @@ -32,7 +31,6 @@
| `GET` | `/api/v2/users/live_locations` | `getUserLiveLocations` | `SharedLocationsResponse` |
| `GET` | `/api/v2/users` | `queryUsers` | `QueryUsersResponse` |
| `POST` | `/api/v2/users/unblock` | `unblockUsers` | `UnblockUsersResponse` |
| `GET` | `/api/v2/chat/unread` | `unreadCounts` | `WrappedUnreadCountsResponse` |
| `PUT` | `/api/v2/users/live_locations` | `updateLiveLocation` | `SharedLocationResponse` |
| `POST` | `/api/v2/users` | `updateUsers` | `UpdateUsersResponse` |
| `PATCH` | `/api/v2/users` | `updateUsersPartial` | `UpdateUsersResponse` |
Expand Down
60 changes: 60 additions & 0 deletions openapi-migration/18-unread-counts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 18 — Unread Counts

**Goal:** Move reading the current user's unread counts ahead of [09](09-users.md): it takes no parameters, answers only counts, and needs none of the `User` restructuring.

**Size:** 0 hand-written method(s) across 0 file(s) → 1 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 |
| --- | --- | --- | --- |
| `GET` | `/api/v2/chat/unread` | `unreadCounts` | `WrappedUnreadCountsResponse` |

## Decisions taken

- **Split out of [09](09-users.md), ahead of it.** Nothing persists the response, and it embeds no
`User`.
- **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `/unread` and `/api/v2/chat/unread`
on the same `v1.UnreadCounts` in the shared `coreRoutes`. It is gated by
`ClassicUnreadCountsEnabled` on both, so the switch changes nothing about who may call it. It is
not in beta or deprecated.
- **Moved off `UserApi`:** `getUnreadCount`, now a `StreamChatClient` method over a new
`UsersRepository`, mirroring `UserApi`.
- **The v10 names stay:** `getUnreadCount` and `GetUnreadCountResponse`, rather than the spec's
`unreadCounts` and `WrappedUnreadCountsResponse`, which say nothing a caller needs.
- **`GetUnreadCountResponse` and the `UnreadCounts*` models are freezed plain models,** with v10's
fields and nullability; the generated types match them field for field. `duration` is non-null.
- **The current user's counts are updated only on success,** through `Result.onSuccess`, as v10 did
by throwing before it reached the update.

## 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.
6 changes: 4 additions & 2 deletions openapi-migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ generated operations in scope, the decisions that group has to make, its risks,
| [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ |
| [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ |
| [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ |
| [09](09-users.md) | Users | 9 | 9 | ☐ |
| [09](09-users.md) | Users | 8 | 8 | ☐ |
| [10](10-messages.md) | Messages & Search | 14 | 12 | ☐ |
| [11](11-channels-and-members.md) | Channels, Members & Sync | 13 | 15 | ☐ |
| [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ |
Expand All @@ -29,9 +29,10 @@ generated operations in scope, the decisions that group has to make, its risks,
| [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 | ☑ |
| [18](18-unread-counts.md) | Unread Counts — split out of 09 | 0 | 1 | ☑ |
| [21](21-custom-data.md) | Custom Data Check — no model still says `extraData` | — | — | ☐ |

**Coverage:** 57 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly
**Coverage:** 56 hand-written methods across 9 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 @@ -202,6 +203,7 @@ surfaces before it reaches `Message` and `ChannelState`:
`channels_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.
- **18** is the current user's unread counts, split out of 09 because it embeds no `User` and nothing persists it.
- **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
30 changes: 28 additions & 2 deletions openapi-migration/tool/generate_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -579,9 +579,9 @@ def match(verb, path):
dict(
num='09', slug='users', title='Users',
hand=['user_api.dart'],
match=owns('/api/v2/users', '/api/v2/chat/unread'),
match=owns('/api/v2/users'),
goal='`User` is the most widely referenced public model in the SDK; this is where keep-vs-adopt costs the '
'most.',
'most. [18](18-unread-counts.md) split the current user\'s unread counts out of it.',
decisions=[
'`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 '
Expand Down Expand Up @@ -989,6 +989,32 @@ def match(verb, path):
risks=[],
done=DONE.replace('- [ ]', '- [x]'),
),
dict(
num='18', slug='unread-counts', title='Unread Counts',
hand=[],
match=only_ops('GET /api/v2/chat/unread'),
goal='Move reading the current user\'s unread counts ahead of [09](09-users.md): it takes no parameters, '
'answers only counts, and needs none of the `User` restructuring.',
decisions=[],
taken=textwrap.dedent("""\
- **Split out of [09](09-users.md), ahead of it.** Nothing persists the response, and it embeds no
`User`.
- **The v2 route is the v1 handler.** `lib/chat/routes.go` mounts `/unread` and `/api/v2/chat/unread`
on the same `v1.UnreadCounts` in the shared `coreRoutes`. It is gated by
`ClassicUnreadCountsEnabled` on both, so the switch changes nothing about who may call it. It is
not in beta or deprecated.
- **Moved off `UserApi`:** `getUnreadCount`, now a `StreamChatClient` method over a new
`UsersRepository`, mirroring `UserApi`.
- **The v10 names stay:** `getUnreadCount` and `GetUnreadCountResponse`, rather than the spec's
`unreadCounts` and `WrappedUnreadCountsResponse`, which say nothing a caller needs.
- **`GetUnreadCountResponse` and the `UnreadCounts*` models are freezed plain models,** with v10's
fields and nullability; the generated types match them field for field. `duration` is non-null.
- **The current user's counts are updated only on success,** through `Result.onSuccess`, as v10 did
by throwing before it reached the update.
"""),
risks=[],
done=DONE.replace('- [ ]', '- [x]'),
),
dict(
num='21', slug='custom-data', title='Custom Data Check',
hand=[],
Expand Down
5 changes: 5 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@
- `StreamChatClient.markChannelsDelivered` returns a `Result<MarkDeliveredResponse>` instead of throwing, and `MarkChannelsDelivered`, the callback `ChannelDeliveryReporter` takes, returns a `Future<Result<void>>`.
- `MessageDelivery` no longer encodes to JSON, compares by value and gains `copyWith`.
- `StreamChatApi.channel.markRead`, `markUnread`, `markUnreadByTimestamp`, `markThreadRead`, `markThreadUnread`, `markAllRead` and `markChannelsDelivered` are removed; call them on `StreamChatClient` instead.
- `StreamChatClient.getUnreadCount` returns a `Result<GetUnreadCountResponse>` instead of throwing.
- `GetUnreadCountResponse` no longer decodes from JSON, is immutable, built through a const constructor, and its `duration` is a non-nullable `String`.
- `UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` no longer decode from or encode to JSON.
- `GetUnreadCountResponse`, `UnreadCountsChannel`, `UnreadCountsThread` and `UnreadCountsChannelType` compare by value and gain `copyWith`.
- `StreamChatApi.user.getUnreadCount` is removed; call `StreamChatClient.getUnreadCount` instead.
- `ChannelModel.extraData` and `Member.extraData` are renamed `custom`, and so are `Channel.extraData`, `Channel.extraDataStream` (now `customStream`), and the `extraData` argument of the `Channel` constructor and of `StreamChatClient.channel`.
- `ChannelModel.disabled`, `hidden`, `muted`, `blocked`, `truncatedAt`, `truncatedBy` and the auto-translation settings, and `Member.notificationsMuted`, `status`, `banFromFutureChannels`, `futureChannelBanExpires` and `deletedAt`, are fields instead of entries in the custom data, so `custom` no longer holds `hidden`, `status` and the rest, and an entry with that name in `custom` or in `copyWith(custom:)` no longer sets the field.
- `ChannelModel.custom` and `Member.custom` hold the custom data exactly as the API returns it, and `toJson` leaves out an entry named like one of the model's own fields.
Expand Down
Loading
Loading