Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
1433b25
refactor(llc)!: route connectGuestUser through the generated client
VelikovPetar Sep 29, 2026
dbf9b2f
Merge branch 'v11' into refactor/flu-846_guest-openapi-migration
VelikovPetar Sep 29, 2026
8339e78
refactor(llc)!: route polls through the generated client
VelikovPetar Oct 1, 2026
040d62f
refactor(llc): address review findings on the polls migration
VelikovPetar Oct 1, 2026
a0b8fe7
Merge remote-tracking branch 'origin/v11' into HEAD
VelikovPetar Oct 1, 2026
fa51edc
Merge branch 'refactor/flu-846_guest-openapi-migration' into refactor…
VelikovPetar Oct 1, 2026
42e56df
refactor(llc): let the API reject poll updates with option ids missing
VelikovPetar Oct 1, 2026
ad777cd
refactor(llc): keep the v10 copyWith on Poll, PollOption and PollVote
VelikovPetar Oct 1, 2026
8bd2b40
docs(repo): correct the poll getters row in the v11 migration guide
VelikovPetar Oct 1, 2026
02ca612
docs(repo): keep v10 copyWith semantics in the openapi-migration rules
VelikovPetar Oct 2, 2026
8a5f068
refactor(llc): share the sort mapper and keep poll client tests in cl…
VelikovPetar Oct 2, 2026
ab5bdef
refactor(llc): apply review remarks on connectGuestUser and the user …
VelikovPetar Oct 2, 2026
9c43e73
Merge remote-tracking branch 'origin/v11' into refactor/flu-846_guest…
VelikovPetar Oct 2, 2026
0eb9266
Merge remote-tracking branch 'origin/refactor/flu-846_guest-openapi-m…
VelikovPetar Oct 2, 2026
4439cf3
refactor(llc): rename the internal guest envelope to CreateGuestUserR…
VelikovPetar Oct 2, 2026
0827c41
Merge remote-tracking branch 'origin/refactor/flu-846_guest-openapi-m…
VelikovPetar Oct 2, 2026
189cec5
Merge remote-tracking branch 'origin/v11' into claude/polls-openapi-m…
VelikovPetar Oct 3, 2026
0a6028d
Merge branch 'v11' into refactor/flu-882_polls_openapi_migration
VelikovPetar Oct 5, 2026
e1ec831
refactor(llc)!: make Moderation a plain model
VelikovPetar Oct 6, 2026
ae62a1b
refactor(llc)!: make ReactionGroup a plain model
VelikovPetar Oct 6, 2026
d4e87e1
refactor(llc)!: make Action a plain model
VelikovPetar Oct 6, 2026
b072b06
refactor(llc)!: make Reaction a plain model
VelikovPetar Oct 6, 2026
4763624
Merge branch 'v11' into refactor/flu-882_polls_openapi_migration
VelikovPetar Oct 6, 2026
03c00a8
refactor(llc)!: make Location a plain model
VelikovPetar Oct 7, 2026
bf21507
Merge remote-tracking branch 'origin/refactor/flu-937_user_blocking_o…
VelikovPetar Oct 7, 2026
627a88b
Merge remote-tracking branch 'origin/refactor/flu-882_polls_openapi_m…
VelikovPetar Oct 7, 2026
769f3df
Merge remote-tracking branch 'origin/refactor/flu-953_user_updates_op…
VelikovPetar Oct 8, 2026
d2c4277
refactor(llc)!: route message reminders through the generated client
VelikovPetar Oct 9, 2026
514d3a7
feat(llc): add Attachment.giphy
VelikovPetar Oct 9, 2026
aa6fde8
Merge branch 'v11' into refactor/flu-882_polls_openapi_migration
VelikovPetar Oct 9, 2026
e8cbf84
Merge branch 'refactor/flu-953_user_updates_openapi_migration' into r…
VelikovPetar Oct 9, 2026
bf016c0
refactor(llc): leave custom data named like a poll field out of the p…
VelikovPetar Oct 9, 2026
85be5a3
Merge branch 'refactor/flu-953_user_updates_openapi_migration' into r…
VelikovPetar Oct 9, 2026
3bec96b
Merge branch 'refactor/flu-882_polls_openapi_migration' into refactor…
VelikovPetar Oct 9, 2026
e627c92
refactor(llc): name the message mapper files consistently and plan th…
VelikovPetar Oct 9, 2026
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
38 changes: 38 additions & 0 deletions .claude/skills/openapi-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ a public signature.
[Persisted models](#persisted-models-the-dataserializable-codec)). A model stored only as a table row, mapped
column by column like `Poll` in `polls`, needs no codec.
- **Which request enums need a hand-written public type** (rule 6).
- **Whether v10 has a `copyWith`, and how it treats `null`.** If it has one, the migrated model keeps that exact
method, `_nullConst` sentinels included. freezed's `copyWith` behaves differently, so read
[Keeping a v10 `copyWith`](#keeping-a-v10-copywith) before relying on it.

**Per method, decide what it returns:**

Expand Down Expand Up @@ -459,6 +462,39 @@ List<Map<String, dynamic>>? _membersToData(List<UserGroupMember>? members) =>
`.g.dart` and `.freezed.dart` files of the models you changed; `--build-filter` deletes every generated output
outside the filter, so never use it.

### Keeping a v10 `copyWith`

**freezed's `copyWith` is not v10's, and swapping one for the other changes behaviour without a compile error.**

- **v10:** `null` means "keep the current value" (`name: name ?? this.name`). A field opts into clearing through
the `_nullConst` sentinel: `Object? maxVotesAllowed = _nullConst`, read back as
`maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?`. Every non-nullable field also
takes a nullable parameter.
- **freezed:** `null` passed for a nullable field sets it to `null`, and a non-nullable field's parameter is
non-nullable. A field whose constructor turns `null` into a default (`id ?? Uuid().v4()`,
`createdAt ?? DateTime.now()`) gets a new value instead of keeping the old one.

The SDK's state handling relies on exactly which fields keep and which clear. `ChannelClientState` clears expired
pins with `message.copyWith(pinExpires: null)` and a deleted draft with `channelState.copyWith(draft: null)`, and
`StreamMessageComposerController.clearQuotedMessage` clears the quote the same way. Each of those works only
because the field has a sentinel. Elsewhere, a value that happens to be `null` passed to a `??` field keeps what
was there. So:

- **If the v10 class has a `copyWith`, keep it verbatim.** Annotate the class `@Freezed(copyWith: false)` and copy
v10's method from `git show <base>:<path>`. Copy the whole signature, every `??`, every `_nullConst` parameter,
and the file's `_NullConst` class. freezed still generates `==`, `hashCode` and `toString`. Copy
`lib/src/core/models/poll.dart` (sentinel on `maxVotesAllowed`) and `poll_option.dart` (sentinel on `id`).
- **Only a class with no v10 `copyWith` uses freezed's.** A `copyWith` it gains that way is new API, so its
semantics are not a break.
- **Diff the two methods parameter by parameter** against the base before moving on: the defaults, the `??`
fallbacks and the sentinels.
- **Mind what freezed counts as data.** For a class with a hand-written constructor, it puts every instance field
in `==`, `hashCode` and `toString`, private fields and `late final` caches included.
- A cache derived only from fields already compared (`Poll.ownVotes`, from `ownVotesAndAnswers`) changes no
result, so it can stay `late final`.
- A field v10 set in the constructor's initializer list from another field (`PollVote.isAnswer`) becomes a
getter.

### Documenting the public surface

Write these as you write the code. `public_member_api_docs` only checks a doc *exists*, so a placeholder survives
Expand Down Expand Up @@ -490,6 +526,8 @@ Budget for this: on a typical feature it is most of the diff, and none of it is
- **Stub each generated response with every field our model carries,** each with a distinct value, and compare
the whole returned envelope, so a field the mapper drops or swaps fails an assertion. Give every method a
failure test too.
- **Pin each kept `copyWith`'s `null` handling:** one test that `null` keeps the `??` fields, and one per
`_nullConst` field that `null` clears it (`poll_test.dart`, `poll_option_test.dart`).
- **Test each temporary converter through its parent's `fromJson`/`toJson`.**
- **Test each `@DataSerializable` model's stored format:** pin `toData()` to a literal map, so a rename that changes
the stored keys fails; round-trip `fromData(toData())`; cover null versus empty for nullable lists. Extend the
Expand Down
189 changes: 188 additions & 1 deletion migrations/v11-migration.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions openapi-migration/02-devices.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ Revisited after landing: the group first adopted the generated `DeviceResponse`,
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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels
included ([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.
Expand Down
2 changes: 2 additions & 0 deletions openapi-migration/03-user-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Follows the [domain-model rules](README.md#domain-models).
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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels
included ([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.
Expand Down
4 changes: 3 additions & 1 deletion openapi-migration/04-roles-guest-and-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 All @@ -113,6 +113,8 @@
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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels
included ([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.
Expand Down
94 changes: 67 additions & 27 deletions openapi-migration/05-polls.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Goal:** First group with real domain models and persistence behind it.

**Size:** 13 hand-written method(s) across 1 file(s) → 13 generated operation(s).
**Size:** 0 hand-written method(s) across 0 file(s) → 13 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 @@ -13,19 +13,6 @@

| File | Method | Returns |
| --- | --- | --- |
| `polls_api.dart` | `createPoll` | `CreatePollResponse` |
| `polls_api.dart` | `getPoll` | `GetPollResponse` |
| `polls_api.dart` | `updatePoll` | `UpdatePollResponse` |
| `polls_api.dart` | `partialUpdatePoll` | `UpdatePollResponse` |
| `polls_api.dart` | `deletePoll` | `EmptyResponse` |
| `polls_api.dart` | `createPollOption` | `CreatePollOptionResponse` |
| `polls_api.dart` | `getPollOption` | `GetPollOptionResponse` |
| `polls_api.dart` | `updatePollOption` | `UpdatePollOptionResponse` |
| `polls_api.dart` | `deletePollOption` | `EmptyResponse` |
| `polls_api.dart` | `castPollVote` | `CastPollVoteResponse` |
| `polls_api.dart` | `removePollVote` | `RemovePollVoteResponse` |
| `polls_api.dart` | `queryPolls` | `QueryPollsResponse` |
| `polls_api.dart` | `queryPollVotes` | `QueryPollVotesResponse` |

### Generated operations that cover it

Expand All @@ -45,11 +32,62 @@
| `PUT` | `/api/v2/polls/{poll_id}/options` | `updatePollOption` | `PollOptionResponse` |
| `PATCH` | `/api/v2/polls/{poll_id}` | `updatePollPartial` | `PollResponse` |

## Decisions to make
## Decisions taken

- `Poll`, `PollOption` and `PollVote` are public and persisted; adopting generated shapes means touching `stream_chat_persistence` in the same PR.
- `VotingVisibility` is ours; the generated equivalent is an inline per-operation enum.
- One generated `PollResponse` answers `createPoll`, `getPoll`, `updatePoll` and `updatePollPartial`, where v10 has `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse`. The v10 envelopes stay; decide with the user how the mapper names its conversions, since one `toModel()` cannot return three types. `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` (cast, remove a vote) have the same shape.
- **`Poll`, `PollOption` and `PollVote` keep their v10 names, fields and defaults,** as `@freezed`
classes with no JSON, mapped from `PollResponseData`, `PollOptionResponseData` and
`PollVoteResponseData` in `lib/src/repository/mapper/polls_mapper.dart`. The uuid default `id` and the
`DateTime.now()` timestamps stay; `latestVotes`, `ownVotes`, `ownAnswers` and `PollVote.isAnswer`
become getters.
- **Equality now includes `extraData`.** v10's `Equatable` props left it out of `Poll` and `PollOption`;
freezed compares every field, and so does `Message`, whose props include its poll. Accepted as a
behavioural break and flagged for review.
- **The eight v10 envelopes collapse to the three the server returns:** `PollResponse` (create, get,
update, partial update), `PollOptionResponse` (create, get, update an option) and `PollVoteResponse`
(cast, remove a vote), each with one `toModel()`. `QueryPollsResponse` and `QueryPollVotesResponse`
keep their names. A rename break beyond the [sanctioned ones](README.md#domain-models), approved for
this group and flagged for review. `PollVoteResponse.vote` is nullable, as the spec marks it.
- **The fields only the generated types carry stay out:** the `*_i18n` maps, `is_answer` (derived from
`answerText`) and `PollVoteResponse.poll`. Adding them is additive. The i18n keys are known fields
(`topLevelFields`), so neither the mapper nor the v1 decoder puts them in `extraData`, and a poll
compares equal whichever path delivered it.
- **Write methods keep taking `Poll` and `PollOption`.** The mapper sends only the writable settings;
the vote summary, `createdBy` and timestamps are not sent. Dedicated request types were considered
and rejected: the draft a caller builds must stay a `Poll`, because a message carries one. Flagged
for review.
- **Option ids are not sent on create.** `PollOptionInput` has none; the server assigns them and
rejects a client id as reserved (confirmed live: v10 answered 400). **`updatePoll` and
`updatePollOption` fail without a request when an option has no id,** since the server requires one.
- **`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of `PaginationParams`.
The generated requests carry only those, and the backend never read the offset or id/date cursors on
these queries. `limit` defaults to 10, `PaginationParams`' default, so an omitted limit pages as in
v10. The responses expose `prev`, so the parameter is usable. Flagged for review.
- **`VotingVisibility` becomes an extension type over its wire string,** following `PushProvider`. An
unknown value is kept rather than rejected, in the mapper, the v1 decoder and persistence.
- **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it.
- **`custom` becomes `extraData` without the keys named after the model's own fields**
(`Poll.topLevelFields`, `PollOption.topLevelFields`), matching v1's flat JSON, which shadowed them.
The requests leave the same keys out of `custom`, as every request mapper does: v2 stores them as
custom data, or drops them on update, where v10's flattened body set the field.
- **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and
`deletePollOption` answer `DurationResponse` and return `Result<void>`. `closePoll` stays a
`partialUpdatePoll` that sets `is_closed`.
- **`Channel.sendPoll` returns `Result<SendMessageResponse>`.** A `createPoll` failure passes through
and no message is sent; a `StreamException` thrown by `sendMessage` (still v1) is caught into a
`Failure`.
- **A missing option or vote id is a `Failure(StreamClientException)`, not an `ArgumentError`,** in
`Channel.castPollVote` and `removePollVote`, matching the migrated never-throws contract.
- **v1 JSON keeps decoding through hand-written converters** (`PollV1JsonConverter`,
`PollVoteV1JsonConverter`) on `Message.poll`, `DraftMessage.poll`, `Event.poll` and `Event.pollVote`.
The WebSocket is v1-only and sends custom data flat, with no `custom` key, so the generated
`fromJson` would throw on it; the converters mirror v10's `fromJson` and tolerate missing keys.
`Event` writes these fields too, so its converters run both ways.
- **`PollOption` gets the `@DataSerializable` codec** for `polls.options`. The stored format nests
custom data under `extra_data` where v10 flattened it, so `schemaVersion` is bumped.
- **`extraData` keeps its name.** Renaming it to `custom` is a migration-wide step for every model at
once — [group 21](21-custom-data-rename.md).
- **Verified live** against the demo app with a before/after harness covering every endpoint, the
error cases and the WebSocket events, and in the sample app on web.

## Risks

Expand All @@ -59,17 +97,19 @@

## Definition of done

- [ ] Every method above either routes through `DefaultApi` or is listed here as deliberately left
- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left
hand-written, with the reason.
- [ ] Public methods return `Future<Result<T>>`; no `getOrThrow()` inside the SDK.
- [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified.
- [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group
- [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] Every model that had a `copyWith` in v10 keeps that exact method, `_nullConst` sentinels
included ([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.
- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break.
- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`.
- [ ] Decisions recorded in this file, and the status box ticked in `README.md`.
- [ ] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation)
- [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.
- [ ] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each
- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each
name states its subject and behaviour.
Loading
Loading