Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
4763624
Merge branch 'v11' into refactor/flu-882_polls_openapi_migration
VelikovPetar Oct 6, 2026
aa6fde8
Merge branch 'v11' into refactor/flu-882_polls_openapi_migration
VelikovPetar Oct 9, 2026
bf016c0
refactor(llc): leave custom data named like a poll field out of the p…
VelikovPetar Oct 9, 2026
7ef7ca6
refactor(llc): tighten the poll docs, changelogs and tests
VelikovPetar Oct 13, 2026
086657e
refactor(llc): switch the poll models to freezed's copyWith
VelikovPetar Oct 13, 2026
27b3ad3
refactor(llc): apply the polls review fixes and bump the cache schema
VelikovPetar Oct 13, 2026
867d5a4
style(llc): format the poll vote test
VelikovPetar Oct 13, 2026
d445345
refactor(llc)!: make poll custom data the wire's custom and rename it
VelikovPetar Oct 13, 2026
92675e6
refactor(llc): apply the polls review suggestions
VelikovPetar Oct 14, 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: 35 additions & 3 deletions .claude/skills/openapi-migration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ 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`.** If it has one, run `scripts/copywith_audit.py <Model>` before deleting it;
[Replacing a v10 `copyWith`](#replacing-a-v10-copywith) says what to do with the report.

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

Expand Down Expand Up @@ -339,9 +341,13 @@ incrementally, not for us.

Three data-shape traps while mapping:

- **`custom` vs `extraData`** (channel / message / user features). The wire puts extra fields in `custom`;
hand-written models flatten them into `extraData`. Write that promotion explicitly, including which keys are
excluded.
- **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 `ChannelModel.hidden` and `User.name`
still are until groups 11 and 09 promote them. 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 Expand Up @@ -457,6 +463,31 @@ 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.

### Replacing a v10 `copyWith`

A model moving onto `@freezed` gives up its hand-written `copyWith` for the generated one, and most of the
difference compiles cleanly: `null` clears a nullable field where v10 kept it, `null` re-runs a constructor default
(`id ?? Uuid().v4()`, `createdAt ?? DateTime.now()`), and a constructor parameter with no field of the same name
and type is not generated at all. [`copywith-audit.md`](copywith-audit.md) has the full table and the procedure;
`scripts/copywith_audit.py <Model>` runs it, on a clean tree, and lists every call site in lib and tests whose
behaviour or compilation changes.

- **Handle every site the report lists** (README rule 2): a compile break gets a local to carry flow analysis, or
a `?? model.field`; a site tagged "v10 kept on null; check" is rewritten as an explicit merge unless the value
is only ever null when the field already is (`Poll.withTranslationsOf`) or the clear is intended; a site tagged
"v10 sentinel, same result" needs nothing.
- **A verdict line means freezed cannot replace this method as it stands.** Keep `@Freezed(copyWith: false)` with
the hand-written method, or reshape the constructor so every parameter is a field of the same name and type,
which is a public API change of its own. `Message` is the known case.
- **Record the semantics change** in the CHANGELOG and the guide, listing the model's fields under the rows of the
audit's table that apply. A model with no v10 `copyWith` gains new API, which is not a break.
- **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 @@ -488,6 +519,7 @@ 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.
- **Flip the tests that pinned a v10 `copyWith`'s `null` handling** to freezed's behaviour; the audit lists them.
- **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
88 changes: 88 additions & 0 deletions .claude/skills/openapi-migration/copywith-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# copyWith audit: moving a model to freezed's copyWith

A model moving onto `@freezed` gives up its hand-written `copyWith` for the generated one. The two treat `null`
differently, freezed can't generate every parameter a hand-written method takes, and most of the difference
compiles cleanly. Run this audit per model before deleting the old method. It lists every call site, in lib and in
tests, whose behaviour or compilation changes, and tells you when freezed's `copyWith` can't replace the model's
at all.

## What changes

| Parameter | v10 hand-written | freezed | How it shows up |
| --- | --- | --- | --- |
| Non-nullable field (`String name`) | `String? name`, null keeps | `String name` | Compile error where a nullable value is passed |
| Nullable field, no sentinel (`String? description`) | null keeps | null clears | Silent: same code, field now wiped |
| Nullable field with `_nullConst` sentinel | null clears | null clears | No change |
| Nullable constructor parameter on a non-nullable field (`String? id` → `id ?? Uuid().v4()`) | null keeps | null runs the constructor default | Silent: a new id or `DateTime.now()` |
| Constructor parameter with no field of the same name (a getter, a private field, a renamed field) | accepted | not generated | Compile error at every call passing it |
| Constructor parameter whose type differs from its field's (`String type` → `MessageType type`) | the field's type | the parameter's type | Compile error where the field's type is passed |

freezed builds `copyWith` from the class's constructor, and keeps a parameter only when a field (not a getter) has
the same name. That is why the last two rows exist.

## Run it

One model per run, named by its class, from anywhere in the repo after `melos bootstrap`:

```bash
python3 .claude/skills/openapi-migration/scripts/copywith_audit.py Poll
```

When two packages declare the class, pass the file: `packages/stream_chat/lib/src/core/models/poll.dart:Poll`. By
default it analyzes every bootstrapped package; `--packages packages/stream_chat packages/stream_chat_flutter …`
narrows it. A run over five packages takes about half a minute.

The model can be on `@Freezed(copyWith: false)` already, or not on freezed at all. For the second, the probe adds
`@freezed`, `with _$Model`, the `part` and the import for the run only. Either way it deletes the hand-written
`copyWith`, regenerates with the `.fvmrc`-pinned SDK, and analyzes twice:

1. As generated. Every error is a compile break.
2. With the `?` dropped from the generated `copyWith` interface. Every new error is a call site passing a value
that may be null to a nullable parameter, which is exactly where "keeps" became "clears".

**What it touches.** Only the model's file and the generated `*.freezed.dart` / `*.g.dart` files of the model's
package, all restored at the end, even on failure. It refuses to start while any of those has uncommitted changes.
Edits anywhere else, including while it runs, are left alone.

## Read the report

A verdict line appears when freezed's `copyWith` can't replace the model's as it stands, because parameters are
missing or retyped (`Message` is the example: `createdAt` is a getter over server and local timestamps,
`quotedMessageId` a private field with a fallback, `type` a `String` turned into a `MessageType`). Then either keep
`@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor so every parameter is a field
of the same name and type, which is a public API change of its own. The sections below the verdict still show what
each option would cost.

Nullable argument sites are tagged:

* **v10 sentinel, same result**: no action.
* **v10 kept on null; check**: decide whether the value can be null at runtime while the field is set. If it is
only ever null when the field already is (`nameI18n: nameI18n ?? fallback`), the result is unchanged. Otherwise
the site needs `value ?? model.field`, or the clear is the intended new behaviour.

Compile breaks in lib usually need a local to carry flow analysis (`translatedName(language)` called twice is not
promoted). Tests that pin the old "null keeps" behaviour flip to the new behaviour.

For a model not yet on freezed, the report covers `copyWith` only. freezed also replaces `==`, `hashCode` and
`toString` (for example, every field counts in `==`, where `Equatable` compared only `props`); check those
separately.

What a changed site means for apps goes in the changelog and the migration guide: list the model's fields under
the rows of the table that apply.

## Known limits

* It finds the hand-written `copyWith` by the shape `Type copyWith({`; a positional or oddly formatted one has to
be removed by hand first.
* A call through `dynamic` or a tear-off isn't type-checked, so it isn't reported.
* A generated file that is untracked and rewritten by the build can't be restored; commit generated files first.

## Results on the poll models (PR #3037)

* `Poll`: 1 compile break in lib (`stream_message_preview_text.dart:96`), `withTranslationsOf` unchanged,
sentinel sites unchanged, 1 test to rewrite (`poll_test.dart:40`). Constructor-default trap on `id`,
`createdAt`, `updatedAt`.
* `PollOption`: no lib changes; 1 test no longer compiles (`poll_option_test.dart:43`).
* `PollVote`: no lib changes; 1 test to rewrite (`poll_vote_test.dart:23`).
* Applying the `Poll` switch for real: analyze clean in all packages, and the only failing non-golden test is the
one pinning v10 behaviour.
Loading
Loading