fetchium 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +12 -5
  2. package/README.md +1 -1
  3. package/dist/cjs/development/QueryAdapter-DUo338ga.js +2 -0
  4. package/dist/cjs/development/QueryAdapter-DUo338ga.js.map +1 -0
  5. package/dist/cjs/development/QueryClient-m7BzCIe9.js +2 -0
  6. package/dist/cjs/development/QueryClient-m7BzCIe9.js.map +1 -0
  7. package/dist/cjs/development/index.js +1 -1
  8. package/dist/cjs/development/index.js.map +1 -1
  9. package/dist/cjs/development/mutation-wUhcGxKl.js +2 -0
  10. package/dist/cjs/development/mutation-wUhcGxKl.js.map +1 -0
  11. package/dist/cjs/development/react/index.js +1 -1
  12. package/dist/cjs/development/rest/index.js +2 -0
  13. package/dist/cjs/development/rest/index.js.map +1 -0
  14. package/dist/cjs/development/topic/index.js +2 -0
  15. package/dist/cjs/development/topic/index.js.map +1 -0
  16. package/dist/cjs/production/QueryAdapter-DUo338ga.js +2 -0
  17. package/dist/cjs/production/QueryAdapter-DUo338ga.js.map +1 -0
  18. package/dist/cjs/production/QueryClient-4T90peFN.js +2 -0
  19. package/dist/cjs/production/QueryClient-4T90peFN.js.map +1 -0
  20. package/dist/cjs/production/index.js +1 -1
  21. package/dist/cjs/production/index.js.map +1 -1
  22. package/dist/cjs/production/mutation-Dk0gznwX.js +2 -0
  23. package/dist/cjs/production/mutation-Dk0gznwX.js.map +1 -0
  24. package/dist/cjs/production/react/index.js +1 -1
  25. package/dist/cjs/production/rest/index.js +2 -0
  26. package/dist/cjs/production/rest/index.js.map +1 -0
  27. package/dist/cjs/production/topic/index.js +2 -0
  28. package/dist/cjs/production/topic/index.js.map +1 -0
  29. package/dist/esm/MutationResult.d.ts +0 -1
  30. package/dist/esm/MutationResult.d.ts.map +1 -1
  31. package/dist/esm/QueryAdapter.d.ts +49 -0
  32. package/dist/esm/QueryAdapter.d.ts.map +1 -0
  33. package/dist/esm/QueryClient.d.ts +26 -4
  34. package/dist/esm/QueryClient.d.ts.map +1 -1
  35. package/dist/esm/QueryResult.d.ts +11 -11
  36. package/dist/esm/QueryResult.d.ts.map +1 -1
  37. package/dist/esm/development/QueryAdapter-Bu5UJjE4.js +14 -0
  38. package/dist/esm/development/QueryAdapter-Bu5UJjE4.js.map +1 -0
  39. package/dist/esm/development/QueryClient-BajBmpnA.js +2572 -0
  40. package/dist/esm/development/QueryClient-BajBmpnA.js.map +1 -0
  41. package/dist/esm/development/index.js +29 -100
  42. package/dist/esm/development/index.js.map +1 -1
  43. package/dist/esm/development/mutation-DAOZE4Ok.js +58 -0
  44. package/dist/esm/development/mutation-DAOZE4Ok.js.map +1 -0
  45. package/dist/esm/development/react/index.js +1 -1
  46. package/dist/esm/development/rest/index.js +142 -0
  47. package/dist/esm/development/rest/index.js.map +1 -0
  48. package/dist/esm/development/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
  49. package/dist/esm/development/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
  50. package/dist/esm/development/stores/async.js +6 -6
  51. package/dist/esm/development/stores/sync.js +5 -5
  52. package/dist/esm/development/topic/index.js +86 -0
  53. package/dist/esm/development/topic/index.js.map +1 -0
  54. package/dist/esm/index.d.ts +5 -4
  55. package/dist/esm/index.d.ts.map +1 -1
  56. package/dist/esm/mutation.d.ts +6 -19
  57. package/dist/esm/mutation.d.ts.map +1 -1
  58. package/dist/esm/production/QueryAdapter-Bu5UJjE4.js +14 -0
  59. package/dist/esm/production/QueryAdapter-Bu5UJjE4.js.map +1 -0
  60. package/dist/esm/production/{QueryClient-BP0Z1rQV.js → QueryClient-KH0Ex_8m.js} +595 -591
  61. package/dist/esm/production/QueryClient-KH0Ex_8m.js.map +1 -0
  62. package/dist/esm/production/index.js +29 -100
  63. package/dist/esm/production/index.js.map +1 -1
  64. package/dist/esm/production/mutation-C7BOChR2.js +58 -0
  65. package/dist/esm/production/mutation-C7BOChR2.js.map +1 -0
  66. package/dist/esm/production/react/index.js +1 -1
  67. package/dist/esm/production/rest/index.js +142 -0
  68. package/dist/esm/production/rest/index.js.map +1 -0
  69. package/dist/esm/production/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
  70. package/dist/esm/production/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
  71. package/dist/esm/production/stores/async.js +6 -6
  72. package/dist/esm/production/stores/sync.js +5 -5
  73. package/dist/esm/production/topic/index.js +86 -0
  74. package/dist/esm/production/topic/index.js.map +1 -0
  75. package/dist/esm/query-types.d.ts +2 -4
  76. package/dist/esm/query-types.d.ts.map +1 -1
  77. package/dist/esm/query.d.ts +17 -39
  78. package/dist/esm/query.d.ts.map +1 -1
  79. package/dist/esm/rest/RESTMutation.d.ts +18 -0
  80. package/dist/esm/rest/RESTMutation.d.ts.map +1 -0
  81. package/dist/esm/rest/RESTQuery.d.ts +24 -0
  82. package/dist/esm/rest/RESTQuery.d.ts.map +1 -0
  83. package/dist/esm/rest/RESTQueryAdapter.d.ts +34 -0
  84. package/dist/esm/rest/RESTQueryAdapter.d.ts.map +1 -0
  85. package/dist/esm/rest/index.d.ts +5 -0
  86. package/dist/esm/rest/index.d.ts.map +1 -0
  87. package/dist/esm/stores/shared.d.ts.map +1 -1
  88. package/dist/esm/testing/MockClient.d.ts +64 -0
  89. package/dist/esm/testing/MockClient.d.ts.map +1 -0
  90. package/dist/esm/testing/auto-generate.d.ts +20 -0
  91. package/dist/esm/testing/auto-generate.d.ts.map +1 -0
  92. package/dist/esm/testing/entity-factory.d.ts +13 -0
  93. package/dist/esm/testing/entity-factory.d.ts.map +1 -0
  94. package/dist/esm/testing/index.d.ts +6 -0
  95. package/dist/esm/testing/index.d.ts.map +1 -0
  96. package/dist/esm/testing/types.d.ts +37 -0
  97. package/dist/esm/testing/types.d.ts.map +1 -0
  98. package/dist/esm/topic/TopicQuery.d.ts +10 -0
  99. package/dist/esm/topic/TopicQuery.d.ts.map +1 -0
  100. package/dist/esm/topic/TopicQueryAdapter.d.ts +43 -0
  101. package/dist/esm/topic/TopicQueryAdapter.d.ts.map +1 -0
  102. package/dist/esm/topic/index.d.ts +3 -0
  103. package/dist/esm/topic/index.d.ts.map +1 -0
  104. package/dist/esm/typeDefs.d.ts +1 -1
  105. package/dist/esm/types.d.ts +9 -4
  106. package/dist/esm/types.d.ts.map +1 -1
  107. package/package.json +51 -4
  108. package/plugin/.claude-plugin/plugin.json +10 -0
  109. package/plugin/agents/fetchium.md +168 -0
  110. package/plugin/docs/api/fetchium-react.md +135 -0
  111. package/plugin/docs/api/fetchium.md +674 -0
  112. package/plugin/docs/api/stores-async.md +219 -0
  113. package/plugin/docs/api/stores-sync.md +133 -0
  114. package/plugin/docs/core/entities.md +351 -0
  115. package/plugin/docs/core/queries.md +600 -0
  116. package/plugin/docs/core/streaming.md +550 -0
  117. package/plugin/docs/core/types.md +374 -0
  118. package/plugin/docs/data/caching.md +298 -0
  119. package/plugin/docs/data/live-data.md +435 -0
  120. package/plugin/docs/data/mutations.md +465 -0
  121. package/plugin/docs/guides/auth.md +318 -0
  122. package/plugin/docs/guides/error-handling.md +351 -0
  123. package/plugin/docs/guides/offline.md +270 -0
  124. package/plugin/docs/guides/testing.md +301 -0
  125. package/plugin/docs/quickstart.md +170 -0
  126. package/plugin/docs/reference/pagination.md +519 -0
  127. package/plugin/docs/reference/rest-queries.md +107 -0
  128. package/plugin/docs/reference/why-signalium.md +364 -0
  129. package/plugin/docs/setup/project-setup.md +319 -0
  130. package/plugin/install.mjs +88 -0
  131. package/plugin/skills/design/SKILL.md +140 -0
  132. package/plugin/skills/teach/SKILL.md +105 -0
  133. package/dist/cjs/development/QueryClient-CpmwggOn.js +0 -2
  134. package/dist/cjs/development/QueryClient-CpmwggOn.js.map +0 -1
  135. package/dist/cjs/production/QueryClient-qi3bR0eD.js +0 -2
  136. package/dist/cjs/production/QueryClient-qi3bR0eD.js.map +0 -1
  137. package/dist/esm/development/QueryClient-DRZtPKFD.js +0 -2568
  138. package/dist/esm/development/QueryClient-DRZtPKFD.js.map +0 -1
  139. package/dist/esm/production/QueryClient-BP0Z1rQV.js.map +0 -1
@@ -0,0 +1,465 @@
1
+ ---
2
+ title: Mutations
3
+ ---
4
+
5
+ In the [Queries](/core/queries) guide, we established that Fetchium is built on the well-known Query-Mutation split: queries _read_ data, mutations _change_ it. We've now covered queries, types, and entities in depth. This guide covers the other half --- mutations.
6
+
7
+ Like queries, mutations in Fetchium are _protocol-agnostic_ at the fundamental level. The base `Mutation` class makes no assumptions about HTTP, REST, or any particular transport. `RESTMutation` is a built-in adapter for JSON REST APIs, just as `RESTQuery` is for queries. The abstract mutation concept is simpler than you might expect: a mutation accepts parameters, performs some action that changes data, and then declares what _side effects_ that action had on the data model.
8
+
9
+ Those side effects are the heart of the system.
10
+
11
+ ---
12
+
13
+ ## The Three Side Effects
14
+
15
+ When data changes on your server, exactly one of three things happened to an entity:
16
+
17
+ 1. **Create** --- a new entity was born
18
+ 2. **Update** --- an existing entity's data changed
19
+ 3. **Delete** --- an entity was removed
20
+
21
+ These three operations are the fundamental vocabulary of data mutation. Every write operation your application performs --- creating a user, editing a post title, removing a comment, toggling a like --- ultimately reduces to one or more of these three effects on your entity model.
22
+
23
+ Fetchium's mutation system is built around this insight. When you define a mutation, you don't just describe the network request --- you _declare_ which entities are created, updated, or deleted as a result. Fetchium then propagates those declarations through the entire reactive system:
24
+
25
+ - **Entity proxies** update in place, so every component displaying that entity sees the new data
26
+ - **Live arrays** add or remove entities based on create/delete events
27
+ - **Live values** recompute their aggregates via the `onCreate`, `onUpdate`, `onDelete` reducers
28
+
29
+ This is the _declarative_ philosophy at work. You describe _what changed_, and Fetchium handles the mechanics of propagating that change everywhere it needs to go. You never need to manually invalidate queries, update cache entries, or re-fetch lists. The entity normalization system does the heavy lifting.
30
+
31
+ {% callout title="Coming from TanStack Query?" type="note" %}
32
+ In TanStack Query, after a mutation you typically call `queryClient.invalidateQueries()` to mark related queries as stale and trigger refetches. This is an _imperative_ approach --- you call it in your `onSuccess` callback and must know which queries to invalidate.
33
+
34
+ Fetchium takes a _declarative_ approach. Your first tool is entity effects: declare which entities were created, updated, or deleted, and every query referencing those entities updates automatically through normalization. For cases where entity effects are not sufficient, you can use the `invalidates` effect to mark specific query classes as stale --- but you declare this on the mutation class itself, not in an imperative callback.
35
+ {% /callout %}
36
+
37
+ ---
38
+
39
+ ## Defining a Mutation
40
+
41
+ As with queries, we'll use the built-in `RESTMutation` adapter for our examples. It handles JSON serialization, content-type headers, and path interpolation for REST APIs. For other protocols, you can extend the base `Mutation` class directly --- see [Custom Mutations](#custom-mutations) below.
42
+
43
+ ```tsx
44
+ import { t } from 'fetchium';
45
+ import { RESTMutation } from 'fetchium/rest';
46
+
47
+ class CreateUser extends RESTMutation {
48
+ params = { name: t.string, email: t.string };
49
+
50
+ path = '/users';
51
+ method = 'POST';
52
+ body = { name: this.params.name, email: this.params.email };
53
+
54
+ result = { id: t.number, name: t.string, email: t.string };
55
+ }
56
+ ```
57
+
58
+ Mutation classes follow the same _template_ rules as query classes (see the [Query Class Rules](/core/queries#query-class-rules) section). Field values are _references_, not evaluated values. `this.params.name` captures a reference that Fetchium resolves when the mutation is executed. The same rules apply: use fields for direct references and string interpolations, use `get*()` methods when you need dynamic logic.
59
+
60
+ {% callout %}
61
+ If you omit the `body` field, no request body is sent. This is the correct behavior for `DELETE` requests and other mutations that don't need a body. If your mutation needs to send data, always wire `body` explicitly from `this.params`.
62
+ {% /callout %}
63
+
64
+ ### Path interpolation
65
+
66
+ Path interpolation works the same as queries --- use template literal syntax with `this.params`:
67
+
68
+ ```tsx
69
+ class UpdateUser extends RESTMutation {
70
+ params = { id: t.id, name: t.string };
71
+
72
+ path = `/users/${this.params.id}`;
73
+ method = 'PUT';
74
+ body = { name: this.params.name };
75
+
76
+ result = { id: t.number, name: t.string };
77
+ }
78
+ ```
79
+
80
+ ### Default method
81
+
82
+ The default HTTP method for `RESTMutation` is `POST`. You can set it to `'POST'`, `'PUT'`, `'DELETE'`, or `'PATCH'`.
83
+
84
+ ### Dynamic overrides
85
+
86
+ For cases where you need more control, `RESTMutation` supports dynamic override methods, just like `RESTQuery`:
87
+
88
+ | Method | Overrides | Description |
89
+ | --------------------- | ---------------- | ------------------------------------------------------------------- |
90
+ | `getPath()` | `path` | Dynamically compute the request URL |
91
+ | `getMethod()` | `method` | Dynamically compute the HTTP method |
92
+ | `getBody()` | `body` | Dynamically compute the request body |
93
+ | `getRequestOptions()` | `requestOptions` | Dynamically compute fetch options (e.g., `baseUrl`, custom headers) |
94
+
95
+ ---
96
+
97
+ ## Executing Mutations
98
+
99
+ Mutations are executed using the `getMutation()` function, which returns a `ReactiveTask`. Unlike queries (which are reactive and fire automatically when their params change), mutations are _imperative_ --- you call `.run()` explicitly when the user takes an action.
100
+
101
+ ```tsx {% mode="react" %}
102
+ import { getMutation } from 'fetchium';
103
+
104
+ function CreateUserForm() {
105
+ const createUser = getMutation(CreateUser);
106
+
107
+ const handleSubmit = async (data) => {
108
+ const result = await createUser.run({ name: data.name, email: data.email });
109
+ console.log('Created:', result);
110
+ };
111
+
112
+ return <form onSubmit={handleSubmit}>...</form>;
113
+ }
114
+ ```
115
+
116
+ ```tsx {% mode="signalium" %}
117
+ import { getMutation } from 'fetchium';
118
+ import { component } from 'signalium/react';
119
+
120
+ const CreateUserForm = component(() => {
121
+ const createUser = getMutation(CreateUser);
122
+
123
+ const handleSubmit = async (data) => {
124
+ const result = await createUser.run({ name: data.name, email: data.email });
125
+ console.log('Created:', result);
126
+ };
127
+
128
+ return <form onSubmit={handleSubmit}>...</form>;
129
+ });
130
+ ```
131
+
132
+ The `ReactiveTask` object exposes properties for tracking the mutation state:
133
+
134
+ | Property | Type | Description |
135
+ | ------------- | ----------------------------- | ------------------------------------- |
136
+ | `run(params)` | `(params) => Promise<Result>` | Execute the mutation |
137
+ | `isPending` | `boolean` | `true` while the request is in flight |
138
+ | `isResolved` | `boolean` | `true` after the request succeeds |
139
+ | `isRejected` | `boolean` | `true` if the request failed |
140
+ | `value` | `Result \| undefined` | The resolved result, if available |
141
+ | `error` | `Error \| undefined` | The error, if the request failed |
142
+
143
+ Because the task is reactive, reading `isPending`, `isResolved`, or `value` inside a reactive context (a `component()` or `reactive()` function) will automatically re-render when the mutation state changes. This makes it straightforward to show loading spinners, disable buttons, or display success/error states.
144
+
145
+ ---
146
+
147
+ ## Declaring Effects
148
+
149
+ Effects are how you tell Fetchium what changed in your data model after a mutation succeeds. There are two ways to declare them: statically on the class, or dynamically via the `getEffects()` method.
150
+
151
+ ### Static effects
152
+
153
+ Define effects directly on the mutation class using the `effects` property. Each effect type (`creates`, `updates`, `deletes`) is an array of tuples: `[EntityClass, data]`.
154
+
155
+ ```tsx
156
+ class UpdateUserName extends RESTMutation {
157
+ params = { id: t.id, name: t.string };
158
+
159
+ path = `/users/${this.params.id}`;
160
+ method = 'PUT';
161
+ body = { name: this.params.name };
162
+
163
+ result = User;
164
+
165
+ effects = {
166
+ updates: [[User, { id: this.params.id, name: this.params.name }]],
167
+ };
168
+ }
169
+ ```
170
+
171
+ When this mutation succeeds, Fetchium fires an _update_ event for the `User` entity with the matching `id`. Every component displaying that user re-renders with the new name. Every live array containing that user reflects the change. No manual intervention needed.
172
+
173
+ ### Dynamic effects with `getEffects()`
174
+
175
+ For effects that depend on the _server response_ (not just the input params), override the `getEffects()` method. Inside this method you have access to `this.params` (the input) and `this.result` (the parsed response):
176
+
177
+ ```tsx
178
+ class CreatePost extends RESTMutation {
179
+ params = { title: t.string, body: t.string };
180
+
181
+ path = '/posts';
182
+ method = 'POST';
183
+
184
+ result = Post;
185
+
186
+ getEffects() {
187
+ return {
188
+ creates: [[Post, this.result]],
189
+ };
190
+ }
191
+ }
192
+ ```
193
+
194
+ This is common for `creates` effects, where the server assigns an `id` and possibly other fields (timestamps, defaults) that you don't know until the response arrives.
195
+
196
+ {% callout %}
197
+ Effects are processed _after_ the response is validated. If the mutation request fails, no effects are applied.
198
+ {% /callout %}
199
+
200
+ ### How effects flow through the system
201
+
202
+ When a mutation fires a `creates` event:
203
+
204
+ 1. The new entity is added to the entity store
205
+ 2. Any active live array watching that entity type (and whose constraints match) automatically includes the new entity
206
+ 3. Any live value watching that entity type fires its `onCreate` reducer
207
+
208
+ When a mutation fires an `updates` event:
209
+
210
+ 1. The existing entity proxy's data is updated in place
211
+ 2. Components reading the changed properties re-render
212
+ 3. Any live value watching that entity type fires its `onUpdate` reducer
213
+
214
+ When a mutation fires a `deletes` event:
215
+
216
+ 1. The entity is removed from the entity store
217
+ 2. Any constrained live array containing that entity removes it
218
+ 3. Any live value watching that entity type fires its `onDelete` reducer
219
+
220
+ This is what makes Fetchium's mutation system _declarative_ rather than _imperative_. You declare the effects once, and the propagation is automatic.
221
+
222
+ ### Invalidating queries
223
+
224
+ The three entity effects (`creates`, `updates`, `deletes`) handle the _majority_ of post-mutation updates. But sometimes a mutation's impact is too complex or too broad to express as individual entity changes. A bulk reorder, a server-side computation that affects many entities at once, or a complex aggregation that you cannot predict from the input --- in these cases, it's simpler to tell Fetchium: "these queries are now stale, refetch them."
225
+
226
+ That's what `invalidates` does. It marks matching query instances as _stale_, so they refetch on the next read:
227
+
228
+ ```tsx
229
+ class ReorderItems extends RESTMutation {
230
+ params = { listId: t.id, order: t.array(t.id) };
231
+
232
+ path = `/lists/${this.params.listId}/reorder`;
233
+ method = 'PUT';
234
+ body = { order: this.params.order };
235
+
236
+ result = { success: t.boolean };
237
+
238
+ effects = {
239
+ invalidates: [GetListItems],
240
+ };
241
+ }
242
+ ```
243
+
244
+ Unlike entity effects (which target _entities_ by typename), `invalidates` targets _query classes_. Passing a query class with no params invalidates _all_ active instances of that class.
245
+
246
+ You can also target specific instances by providing a _param subset_ --- a partial set of params that must match:
247
+
248
+ ```tsx
249
+ class BulkUpdateUserPosts extends RESTMutation {
250
+ params = { userId: t.id, status: t.string };
251
+
252
+ path = `/users/${this.params.userId}/posts/bulk-update`;
253
+ method = 'POST';
254
+ body = { status: this.params.status };
255
+
256
+ result = { count: t.number };
257
+
258
+ effects = {
259
+ invalidates: [[GetUserPosts, { userId: this.params.userId }]],
260
+ };
261
+ }
262
+ ```
263
+
264
+ This invalidates all `GetUserPosts` instances where `userId` matches the mutation's `userId` param, but leaves `GetUserPosts` instances for _other_ users untouched. The matching is a _subset_ check: if the instance has `{ userId: 42, status: 'published' }` and the subset is `{ userId: 42 }`, it matches. Any params not mentioned in the subset are ignored.
265
+
266
+ You can combine entity effects and query invalidation in the same mutation:
267
+
268
+ ```tsx
269
+ effects = {
270
+ updates: [[User, { id: this.params.userId, name: this.params.name }]],
271
+ invalidates: [GetLeaderboard],
272
+ };
273
+ ```
274
+
275
+ Here, the entity effect surgically updates the user's name everywhere it appears, while `invalidates` handles the leaderboard --- whose rankings may shift in ways you can't predict from the input alone.
276
+
277
+ {% callout title="invalidates is the escape hatch" type="note" %}
278
+ Entity effects should be your _first choice_ for post-mutation updates. They are precise, efficient, and work with optimistic updates and live data. Use `invalidates` when entity effects are not sufficient --- when the mutation's impact on the data model is too complex, too broad, or depends on server-side logic you don't want to replicate on the client.
279
+ {% /callout %}
280
+
281
+ ---
282
+
283
+ ## Optimistic Updates
284
+
285
+ Optimistic updates let you apply mutation effects _immediately_, before the server responds. This makes your UI feel instant for predictable operations.
286
+
287
+ Set `optimisticUpdates = true` on the mutation class:
288
+
289
+ ```tsx
290
+ class ToggleLike extends RESTMutation {
291
+ params = { postId: t.id, liked: t.boolean };
292
+
293
+ path = `/posts/${this.params.postId}/like`;
294
+ method = 'PUT';
295
+ body = { liked: this.params.liked };
296
+
297
+ result = Post;
298
+ optimisticUpdates = true;
299
+
300
+ effects = {
301
+ updates: [[Post, { id: this.params.postId, liked: this.params.liked }]],
302
+ };
303
+ }
304
+ ```
305
+
306
+ When you execute this mutation:
307
+
308
+ 1. The effects are applied to the entity store _immediately_ --- the UI updates before any network request
309
+ 2. The network request is sent in the background
310
+ 3. If the request succeeds, the optimistic data is replaced with the real server response
311
+ 4. If the request fails, the optimistic changes are _rolled back_ to the previous state
312
+
313
+ Optimistic updates work _because_ effects are declarative. Fetchium knows exactly what data was changed (the entity, the fields, the values), so it can snapshot the previous state and restore it on failure. This would not be possible with an imperative cache manipulation API.
314
+
315
+ {% callout type="warning" %}
316
+ Optimistic updates work best for simple, predictable changes --- toggling a boolean, incrementing a counter, updating a text field. For complex mutations where the server may transform the data significantly (e.g., generating slugs, computing derived fields), consider waiting for the real response instead.
317
+ {% /callout %}
318
+
319
+ {% callout type="warning" %}
320
+ If a mutation with optimistic updates fails, the rollback restores the entity to its previous state. Make sure your UI handles the error case gracefully --- for example, by showing a toast notification or retry button.
321
+ {% /callout %}
322
+
323
+ ---
324
+
325
+ ## Custom Mutations
326
+
327
+ `RESTMutation` is an adapter for JSON REST APIs. But mutations as a concept are protocol-agnostic. When your use case doesn't fit REST --- GraphQL, file uploads, WebSocket messages, RPC calls --- you build a **`QueryAdapter`** that handles the transport and a **`Mutation`** subclass that stays purely declarative.
328
+
329
+ The same adapter that handles queries can also handle mutations by implementing `sendMutation(ctx, signal)`. This means custom query and mutation transports for the same protocol live in one place:
330
+
331
+ ```ts
332
+ import { QueryAdapter, Mutation, t } from 'fetchium';
333
+ import type { Query } from 'fetchium';
334
+
335
+ class MyAdapter extends QueryAdapter {
336
+ async send(ctx: Query, signal: AbortSignal): Promise<unknown> {
337
+ // ... query transport
338
+ }
339
+
340
+ override async sendMutation(
341
+ ctx: Mutation,
342
+ signal: AbortSignal,
343
+ ): Promise<unknown> {
344
+ const m = ctx as UploadAvatarMutation;
345
+ const formData = new FormData();
346
+ formData.append('file', m.params.file);
347
+
348
+ const response = await fetch(`/users/${m.params.userId}/avatar`, {
349
+ method: 'POST',
350
+ body: formData,
351
+ signal,
352
+ });
353
+
354
+ return response.json();
355
+ }
356
+ }
357
+ ```
358
+
359
+ The mutation class is purely declarative:
360
+
361
+ ```ts
362
+ import { Mutation, t } from 'fetchium';
363
+
364
+ class UploadAvatar extends Mutation {
365
+ static override adapter = MyAdapter;
366
+
367
+ params = { userId: t.id, file: t.any };
368
+ result = { url: t.string };
369
+
370
+ getIdentityKey() {
371
+ return 'upload-avatar';
372
+ }
373
+ }
374
+ ```
375
+
376
+ Inside `sendMutation()`:
377
+
378
+ - **`ctx`** --- the mutation execution context, cast to your mutation type; all fields are resolved to their real values
379
+ - **`signal`** --- an `AbortSignal` for cancellation
380
+ - **`this.queryClient`** --- call `this.queryClient.getContext()` to access `log` and any other context properties
381
+
382
+ Custom mutations participate in the same effects system as `RESTMutation`. You can define `effects` or `getEffects()` on any mutation class, and the entity store, live data, and components will react to them identically.
383
+
384
+ ---
385
+
386
+ ## Retry Configuration
387
+
388
+ By default, mutations do _not_ retry on failure. This is deliberate --- retrying a `POST` that creates a resource could result in duplicates, and retrying a `DELETE` might fail because the resource is already gone. The safe default is to fail and let the application decide what to do.
389
+
390
+ If you have an idempotent mutation where retries are safe, you can configure retry behavior using the `config` property:
391
+
392
+ ```tsx
393
+ class UpdateUserName extends RESTMutation {
394
+ params = { id: t.id, name: t.string };
395
+
396
+ path = `/users/${this.params.id}`;
397
+ method = 'PUT';
398
+ body = { name: this.params.name };
399
+
400
+ result = User;
401
+
402
+ config = {
403
+ retry: {
404
+ retries: 3,
405
+ retryDelay: (attempt) => 1000 * Math.pow(2, attempt),
406
+ },
407
+ };
408
+ }
409
+ ```
410
+
411
+ The `retry` option accepts:
412
+
413
+ | Value | Behavior |
414
+ | ---------------------- | ------------------------------------------------------------ |
415
+ | `false` | Never retry (default for mutations) |
416
+ | A number (e.g., `3`) | Retry up to that many times with default exponential backoff |
417
+ | A `RetryConfig` object | Full control over retry count and delay strategy |
418
+
419
+ For more details on retry configuration, see the [Error Handling](/guides/error-handling) guide.
420
+
421
+ ---
422
+
423
+ ## Identity Keys
424
+
425
+ Every mutation has a identity key that uniquely identifies it. For `RESTMutation`, the default identity key is derived from the method and path:
426
+
427
+ ```
428
+ POST:/users
429
+ PUT:/users/42
430
+ ```
431
+
432
+ You can override this by implementing `getIdentityKey()`:
433
+
434
+ ```tsx
435
+ class CreateUser extends RESTMutation {
436
+ params = { name: t.string, email: t.string };
437
+
438
+ path = '/users';
439
+ method = 'POST';
440
+
441
+ result = { id: t.number, name: t.string, email: t.string };
442
+
443
+ getIdentityKey() {
444
+ return 'create-user';
445
+ }
446
+ }
447
+ ```
448
+
449
+ The identity key is used internally to deduplicate mutation definitions. Two mutation classes with the same identity key will share the same underlying mutation instance within a `QueryClient`.
450
+
451
+ ---
452
+
453
+ ## Next Steps
454
+
455
+ {% quick-links %}
456
+
457
+ {% quick-link title="Live Data" icon="installation" href="/data/live-data" description="See how mutation effects flow through live arrays and live values" /%}
458
+
459
+ {% quick-link title="Caching & Refetching" icon="presets" href="/data/caching" description="Understand cache invalidation patterns and when to use __refetch()" /%}
460
+
461
+ {% quick-link title="Entities" icon="plugins" href="/core/entities" description="How entity normalization enables automatic cross-query updates" /%}
462
+
463
+ {% quick-link title="Error Handling" icon="theming" href="/guides/error-handling" description="Handle mutation failures, retries, and error states" /%}
464
+
465
+ {% /quick-links %}