fetchium 0.1.0 → 0.1.1

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 (138) hide show
  1. package/CHANGELOG.md +6 -5
  2. package/dist/cjs/development/QueryClient-CLi3ONNM.js +2 -0
  3. package/dist/cjs/development/QueryClient-CLi3ONNM.js.map +1 -0
  4. package/dist/cjs/development/QueryController-BQA49OYU.js +2 -0
  5. package/dist/cjs/development/QueryController-BQA49OYU.js.map +1 -0
  6. package/dist/cjs/development/index.js +1 -1
  7. package/dist/cjs/development/index.js.map +1 -1
  8. package/dist/cjs/development/mutation-CikIl_6k.js +2 -0
  9. package/dist/cjs/development/mutation-CikIl_6k.js.map +1 -0
  10. package/dist/cjs/development/react/index.js +1 -1
  11. package/dist/cjs/development/rest/index.js +2 -0
  12. package/dist/cjs/development/rest/index.js.map +1 -0
  13. package/dist/cjs/development/topic/index.js +2 -0
  14. package/dist/cjs/development/topic/index.js.map +1 -0
  15. package/dist/cjs/production/QueryClient-N0MJmuHW.js +2 -0
  16. package/dist/cjs/production/QueryClient-N0MJmuHW.js.map +1 -0
  17. package/dist/cjs/production/QueryController-BQA49OYU.js +2 -0
  18. package/dist/cjs/production/QueryController-BQA49OYU.js.map +1 -0
  19. package/dist/cjs/production/index.js +1 -1
  20. package/dist/cjs/production/index.js.map +1 -1
  21. package/dist/cjs/production/mutation-P_Yb4LI9.js +2 -0
  22. package/dist/cjs/production/mutation-P_Yb4LI9.js.map +1 -0
  23. package/dist/cjs/production/react/index.js +1 -1
  24. package/dist/cjs/production/rest/index.js +2 -0
  25. package/dist/cjs/production/rest/index.js.map +1 -0
  26. package/dist/cjs/production/topic/index.js +2 -0
  27. package/dist/cjs/production/topic/index.js.map +1 -0
  28. package/dist/esm/MutationResult.d.ts +0 -1
  29. package/dist/esm/MutationResult.d.ts.map +1 -1
  30. package/dist/esm/QueryClient.d.ts +26 -4
  31. package/dist/esm/QueryClient.d.ts.map +1 -1
  32. package/dist/esm/QueryController.d.ts +49 -0
  33. package/dist/esm/QueryController.d.ts.map +1 -0
  34. package/dist/esm/QueryResult.d.ts +10 -10
  35. package/dist/esm/QueryResult.d.ts.map +1 -1
  36. package/dist/esm/development/QueryClient-Dtde3pss.js +2572 -0
  37. package/dist/esm/development/QueryClient-Dtde3pss.js.map +1 -0
  38. package/dist/esm/development/QueryController-Ch_ncxiI.js +14 -0
  39. package/dist/esm/development/QueryController-Ch_ncxiI.js.map +1 -0
  40. package/dist/esm/development/index.js +29 -100
  41. package/dist/esm/development/index.js.map +1 -1
  42. package/dist/esm/development/mutation-UZshUQAf.js +58 -0
  43. package/dist/esm/development/mutation-UZshUQAf.js.map +1 -0
  44. package/dist/esm/development/react/index.js +1 -1
  45. package/dist/esm/development/rest/index.js +142 -0
  46. package/dist/esm/development/rest/index.js.map +1 -0
  47. package/dist/esm/development/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
  48. package/dist/esm/development/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
  49. package/dist/esm/development/stores/async.js +6 -6
  50. package/dist/esm/development/stores/sync.js +5 -5
  51. package/dist/esm/development/topic/index.js +86 -0
  52. package/dist/esm/development/topic/index.js.map +1 -0
  53. package/dist/esm/index.d.ts +5 -4
  54. package/dist/esm/index.d.ts.map +1 -1
  55. package/dist/esm/mutation.d.ts +6 -19
  56. package/dist/esm/mutation.d.ts.map +1 -1
  57. package/dist/esm/production/{QueryClient-BP0Z1rQV.js → QueryClient-YqnBxFy1.js} +972 -968
  58. package/dist/esm/production/QueryClient-YqnBxFy1.js.map +1 -0
  59. package/dist/esm/production/QueryController-Ch_ncxiI.js +14 -0
  60. package/dist/esm/production/QueryController-Ch_ncxiI.js.map +1 -0
  61. package/dist/esm/production/index.js +29 -100
  62. package/dist/esm/production/index.js.map +1 -1
  63. package/dist/esm/production/mutation-pgFl1uIY.js +58 -0
  64. package/dist/esm/production/mutation-pgFl1uIY.js.map +1 -0
  65. package/dist/esm/production/react/index.js +1 -1
  66. package/dist/esm/production/rest/index.js +142 -0
  67. package/dist/esm/production/rest/index.js.map +1 -0
  68. package/dist/esm/production/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
  69. package/dist/esm/production/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
  70. package/dist/esm/production/stores/async.js +6 -6
  71. package/dist/esm/production/stores/sync.js +5 -5
  72. package/dist/esm/production/topic/index.js +86 -0
  73. package/dist/esm/production/topic/index.js.map +1 -0
  74. package/dist/esm/query-types.d.ts +2 -4
  75. package/dist/esm/query-types.d.ts.map +1 -1
  76. package/dist/esm/query.d.ts +17 -39
  77. package/dist/esm/query.d.ts.map +1 -1
  78. package/dist/esm/rest/RESTMutation.d.ts +18 -0
  79. package/dist/esm/rest/RESTMutation.d.ts.map +1 -0
  80. package/dist/esm/rest/RESTQuery.d.ts +24 -0
  81. package/dist/esm/rest/RESTQuery.d.ts.map +1 -0
  82. package/dist/esm/rest/RESTQueryController.d.ts +34 -0
  83. package/dist/esm/rest/RESTQueryController.d.ts.map +1 -0
  84. package/dist/esm/rest/index.d.ts +5 -0
  85. package/dist/esm/rest/index.d.ts.map +1 -0
  86. package/dist/esm/stores/shared.d.ts.map +1 -1
  87. package/dist/esm/testing/MockClient.d.ts +64 -0
  88. package/dist/esm/testing/MockClient.d.ts.map +1 -0
  89. package/dist/esm/testing/auto-generate.d.ts +20 -0
  90. package/dist/esm/testing/auto-generate.d.ts.map +1 -0
  91. package/dist/esm/testing/entity-factory.d.ts +13 -0
  92. package/dist/esm/testing/entity-factory.d.ts.map +1 -0
  93. package/dist/esm/testing/index.d.ts +6 -0
  94. package/dist/esm/testing/index.d.ts.map +1 -0
  95. package/dist/esm/testing/types.d.ts +37 -0
  96. package/dist/esm/testing/types.d.ts.map +1 -0
  97. package/dist/esm/topic/TopicQuery.d.ts +10 -0
  98. package/dist/esm/topic/TopicQuery.d.ts.map +1 -0
  99. package/dist/esm/topic/TopicQueryController.d.ts +43 -0
  100. package/dist/esm/topic/TopicQueryController.d.ts.map +1 -0
  101. package/dist/esm/topic/index.d.ts +3 -0
  102. package/dist/esm/topic/index.d.ts.map +1 -0
  103. package/dist/esm/typeDefs.d.ts +1 -1
  104. package/dist/esm/types.d.ts +9 -4
  105. package/dist/esm/types.d.ts.map +1 -1
  106. package/package.json +51 -4
  107. package/plugin/.claude-plugin/plugin.json +10 -0
  108. package/plugin/agents/fetchium.md +168 -0
  109. package/plugin/docs/api/fetchium-react.md +135 -0
  110. package/plugin/docs/api/fetchium.md +674 -0
  111. package/plugin/docs/api/stores-async.md +219 -0
  112. package/plugin/docs/api/stores-sync.md +133 -0
  113. package/plugin/docs/core/entities.md +351 -0
  114. package/plugin/docs/core/queries.md +600 -0
  115. package/plugin/docs/core/streaming.md +550 -0
  116. package/plugin/docs/core/types.md +374 -0
  117. package/plugin/docs/data/caching.md +298 -0
  118. package/plugin/docs/data/live-data.md +435 -0
  119. package/plugin/docs/data/mutations.md +465 -0
  120. package/plugin/docs/guides/auth.md +318 -0
  121. package/plugin/docs/guides/error-handling.md +351 -0
  122. package/plugin/docs/guides/offline.md +270 -0
  123. package/plugin/docs/guides/testing.md +301 -0
  124. package/plugin/docs/quickstart.md +170 -0
  125. package/plugin/docs/reference/pagination.md +519 -0
  126. package/plugin/docs/reference/rest-queries.md +107 -0
  127. package/plugin/docs/reference/why-signalium.md +364 -0
  128. package/plugin/docs/setup/project-setup.md +319 -0
  129. package/plugin/install.mjs +88 -0
  130. package/plugin/skills/design/SKILL.md +140 -0
  131. package/plugin/skills/teach/SKILL.md +105 -0
  132. package/dist/cjs/development/QueryClient-CpmwggOn.js +0 -2
  133. package/dist/cjs/development/QueryClient-CpmwggOn.js.map +0 -1
  134. package/dist/cjs/production/QueryClient-qi3bR0eD.js +0 -2
  135. package/dist/cjs/production/QueryClient-qi3bR0eD.js.map +0 -1
  136. package/dist/esm/development/QueryClient-DRZtPKFD.js +0 -2568
  137. package/dist/esm/development/QueryClient-DRZtPKFD.js.map +0 -1
  138. package/dist/esm/production/QueryClient-BP0Z1rQV.js.map +0 -1
@@ -0,0 +1,550 @@
1
+ ---
2
+ title: Streaming
3
+ ---
4
+
5
+ Fetchium supports real-time updates through multiple streaming mechanisms --- query-level subscriptions, topic-based streaming, entity subscriptions, polling, and custom transports. Because all streaming integrates directly with Fetchium's entity event system, incoming data flows through the same normalization and live data pipelines as mutations --- live arrays update, live values recompute, and components re-render automatically.
6
+
7
+ ---
8
+
9
+ ## Mutation Events
10
+
11
+ All streaming data flows through the same `MutationEvent` type used by mutations. There are three event types:
12
+
13
+ ### Create
14
+
15
+ Signals that a new entity was created. The `data` object must include the entity's `id` field.
16
+
17
+ ```tsx
18
+ {
19
+ type: 'create',
20
+ typename: 'Message',
21
+ data: { id: '42', text: 'Hello!', channelId: 1 }
22
+ }
23
+ ```
24
+
25
+ When a `create` event fires, any live array watching the given typename (and whose constraints match the entity's data) will automatically add the new entity.
26
+
27
+ ### Update
28
+
29
+ Signals that an existing entity's data changed. Fetchium merges the incoming data with the entity's current cached state.
30
+
31
+ ```tsx
32
+ {
33
+ type: 'update',
34
+ typename: 'Message',
35
+ data: { id: '42', text: 'Hello! (edited)' }
36
+ }
37
+ ```
38
+
39
+ Partial updates are supported --- you only need to include the fields that changed, plus the `id`. Any component reading the updated fields will re-render; components reading only unchanged fields will not.
40
+
41
+ ### Delete
42
+
43
+ Signals that an entity was removed. The `data` field is the entity's ID (string or number).
44
+
45
+ ```tsx
46
+ {
47
+ type: 'delete',
48
+ typename: 'Message',
49
+ data: '42'
50
+ }
51
+ ```
52
+
53
+ When a `delete` event fires, the entity is removed from any live arrays that contain it, and live values with `onDelete` reducers are updated.
54
+
55
+ ---
56
+
57
+ ## Query Subscriptions
58
+
59
+ Queries can opt into real-time updates by providing a `subscribe` function in their config. This is a low-level hook that activates when the query activates and cleans up when it deactivates.
60
+
61
+ ```tsx
62
+ class GetPrices extends RESTQuery {
63
+ path = '/prices';
64
+
65
+ result = {
66
+ prices: t.liveArray(Price),
67
+ };
68
+
69
+ getConfig() {
70
+ return {
71
+ subscribe: (onEvent) => {
72
+ const ws = new WebSocket('ws://api.example.com/prices');
73
+
74
+ ws.onmessage = (e) => {
75
+ onEvent(JSON.parse(e.data));
76
+ };
77
+
78
+ return () => ws.close(); // cleanup
79
+ },
80
+ };
81
+ }
82
+ }
83
+ ```
84
+
85
+ The `subscribe` function receives an `onEvent` callback that accepts `MutationEvent` objects and returns a cleanup function. Fetchium calls `subscribe` when the query activates (a component reads it) and calls the cleanup function when the query deactivates (all observers disconnect).
86
+
87
+ {% callout %}
88
+ The `subscribe` config is a low-level building block. For polling, use the built-in `poll()` helper. For topic-based streaming (WebSocket message buses, SSE, pub/sub), use [TopicQuery](#topic-queries) --- which provides a declarative, controller-based approach.
89
+ {% /callout %}
90
+
91
+ ### Polling
92
+
93
+ For simpler real-time needs --- or when WebSocket infrastructure is not available --- Fetchium supports polling as a subscription mechanism. Import `poll` from `fetchium/subscriptions/polling` and assign it to the `subscribe` config option:
94
+
95
+ ```tsx
96
+ import { poll } from 'fetchium/subscriptions/polling';
97
+
98
+ class GetNotifications extends RESTQuery {
99
+ path = '/notifications';
100
+
101
+ result = {
102
+ notifications: t.liveArray(Notification),
103
+ };
104
+
105
+ config = {
106
+ subscribe: poll({ interval: 5000 }),
107
+ };
108
+ }
109
+ ```
110
+
111
+ When a component is reading from this query, Fetchium re-fetches the endpoint at the configured interval. The response is diffed against the entity cache, and any changes flow through the entity event system --- live arrays and live values update automatically.
112
+
113
+ {% callout %}
114
+ Polling follows the same demand-driven lifecycle as all subscriptions. Fetchium only polls while at least one component or reactive function is reading from the query. When all observers disconnect, polling stops.
115
+ {% /callout %}
116
+
117
+ ### Polling vs. push subscriptions
118
+
119
+ | | Polling | Push subscriptions |
120
+ | ----------------------- | ------------------------------------ | ------------------------------------------- |
121
+ | **Transport** | HTTP (re-fetches the same endpoint) | Any (WebSocket, SSE, custom) |
122
+ | **Latency** | Bounded by interval | Near real-time |
123
+ | **Server requirements** | None (standard REST endpoint) | Server must push events |
124
+ | **Best for** | Low-frequency updates, simple setups | High-frequency updates, chat, collaboration |
125
+
126
+ Both mechanisms feed into the same entity event system, so you can mix and match. Use polling for some queries and push subscriptions for others --- the live data layer does not care where events originate.
127
+
128
+ ---
129
+
130
+ ## Topic Queries
131
+
132
+ For applications with a centralized message bus --- a single WebSocket connection, an SSE endpoint, a pub/sub system --- `TopicQuery` provides a declarative adapter. Instead of manually wiring `subscribe` callbacks per query, you define _topics_ and let a controller manage the connection lifecycle.
133
+
134
+ ### Defining a topic query
135
+
136
+ A topic query extends `TopicQuery` and provides a `topic` field and a `result` shape. Import from `fetchium/topic`:
137
+
138
+ ```tsx
139
+ import { t } from 'fetchium';
140
+ import { TopicQuery } from 'fetchium/topic';
141
+
142
+ class GetPrices extends MyTopicQuery {
143
+ topic = 'prices:live';
144
+
145
+ result = {
146
+ prices: t.liveArray(Price),
147
+ };
148
+ }
149
+ ```
150
+
151
+ Topics can be parameterized using `this.params`, just like paths in `RESTQuery`:
152
+
153
+ ```tsx
154
+ class GetBalances extends MyTopicQuery {
155
+ params = { walletId: t.string };
156
+
157
+ topic = `balances:${this.params.walletId}`;
158
+
159
+ result = {
160
+ balances: t.liveArray(Balance),
161
+ };
162
+ }
163
+ ```
164
+
165
+ The identity key for a topic query is `topic:${topic}` --- two queries with the same topic and params share the same cache entry and are deduplicated.
166
+
167
+ ### Implementing a controller
168
+
169
+ The `TopicQueryController` is the bridge between your message bus and Fetchium. Extend it and implement two abstract methods:
170
+
171
+ ```tsx
172
+ import { TopicQueryController } from 'fetchium/topic';
173
+
174
+ class MyStreamController extends TopicQueryController {
175
+ private ws: WebSocket;
176
+
177
+ constructor(url: string) {
178
+ super();
179
+ this.ws = new WebSocket(url);
180
+
181
+ this.ws.onmessage = (e) => {
182
+ const msg = JSON.parse(e.data);
183
+
184
+ if (msg.type === 'topic-data') {
185
+ // Initial data for a topic
186
+ this.fulfillTopic(msg.topic, msg.data);
187
+ } else if (msg.type === 'topic-error') {
188
+ // Topic subscription failed
189
+ this.rejectTopic(msg.topic, new Error(msg.error));
190
+ } else if (msg.type === 'event') {
191
+ // Ongoing mutation event
192
+ this.sendMutationEvent(msg.event);
193
+ }
194
+ };
195
+ }
196
+
197
+ subscribe(topic: string): void {
198
+ this.ws.send(JSON.stringify({ action: 'subscribe', topic }));
199
+ }
200
+
201
+ unsubscribe(topic: string): void {
202
+ this.ws.send(JSON.stringify({ action: 'unsubscribe', topic }));
203
+ this.clearTopic(topic);
204
+ }
205
+ }
206
+ ```
207
+
208
+ The controller has several protected helper methods:
209
+
210
+ | Method | Description |
211
+ | --------------------------- | ------------------------------------------------------------------------------------------------------ |
212
+ | `fulfillTopic(topic, data)` | Resolve the query with initial data. Can be called before or after the query activates. |
213
+ | `rejectTopic(topic, error)` | Reject the query with an error. Can be called before or after the query activates. |
214
+ | `sendMutationEvent(event)` | Push a `MutationEvent` through Fetchium's entity event system. |
215
+ | `clearTopic(topic)` | Clear buffered state for a topic. Call this in `unsubscribe` to reset for the next subscription cycle. |
216
+ | `clearAll()` | Clear all buffered topic state. Useful when resetting the connection. |
217
+
218
+ ### Registering the controller
219
+
220
+ Pass the controller to `QueryClient` in the `controllers` array, the same way you register a `RESTQueryController`:
221
+
222
+ ```tsx
223
+ import { QueryClient } from 'fetchium';
224
+ import { RESTQueryController } from 'fetchium/rest';
225
+
226
+ const queryClient = new QueryClient({
227
+ controllers: [
228
+ new RESTQueryController({ baseUrl: '/api' }),
229
+ new MyStreamController('ws://api.example.com/stream'),
230
+ ],
231
+ });
232
+ ```
233
+
234
+ Then make your topic query classes reference the controller:
235
+
236
+ ```tsx
237
+ abstract class MyTopicQuery extends TopicQuery {
238
+ static override controller = MyStreamController;
239
+ }
240
+ ```
241
+
242
+ ### Pre-fulfillment
243
+
244
+ A powerful feature of the controller is that `fulfillTopic` can be called _before_ the query activates. If your message bus proactively sends data for topics it knows the page will need, the controller can buffer that data:
245
+
246
+ ```tsx
247
+ // Data arrives from the stream before any component subscribes
248
+ this.fulfillTopic('prices:live', { prices: [...] });
249
+
250
+ // Later, when a component mounts and reads GetPrices,
251
+ // the query resolves immediately with the buffered data
252
+ ```
253
+
254
+ This enables smart pre-fetching strategies where the server pushes data ahead of the UI without any explicit prefetch calls.
255
+
256
+ ### Lifecycle
257
+
258
+ The full lifecycle of a topic query:
259
+
260
+ 1. **Component reads the query** --- Fetchium calls `send()` on the controller, which creates a deferred promise and calls your `subscribe(topic)` implementation.
261
+ 2. **Controller subscribes** --- Your implementation connects to the message bus for this topic (e.g., sends a subscribe message over WebSocket).
262
+ 3. **Initial data arrives** --- Your `onmessage` handler calls `fulfillTopic(topic, data)`, resolving the deferred promise. The component renders with the data.
263
+ 4. **Ongoing updates** --- Your handler calls `sendMutationEvent(event)` for each update. Live arrays and live values react automatically.
264
+ 5. **Component unmounts** --- Fetchium calls your `unsubscribe(topic)` implementation. Your code disconnects from the message bus for this topic.
265
+
266
+ If the query reactivates later, the cycle repeats from step 1.
267
+
268
+ ---
269
+
270
+ ## Entity Subscriptions
271
+
272
+ Entities can opt into real-time updates by defining a `__subscribe` method. When an entity with `__subscribe` is actively observed --- read by a mounted component or watched by a reactive function --- Fetchium calls `__subscribe` to establish the connection. When all observers disconnect, the cleanup function is called to tear down the connection.
273
+
274
+ ```tsx
275
+ import { Entity, t } from 'fetchium';
276
+
277
+ class Message extends Entity {
278
+ __typename = t.typename('Message');
279
+ id = t.id;
280
+
281
+ text = t.string;
282
+ channelId = t.number;
283
+
284
+ __subscribe(onEvent: (event: MutationEvent) => void) {
285
+ const ws = new WebSocket(`ws://api.example.com/messages/${this.id}`);
286
+
287
+ ws.onmessage = (e) => {
288
+ const data = JSON.parse(e.data);
289
+ onEvent({ type: 'update', typename: 'Message', data });
290
+ };
291
+
292
+ return () => ws.close(); // cleanup
293
+ }
294
+ }
295
+ ```
296
+
297
+ The `onEvent` callback accepts a `MutationEvent` and routes it through Fetchium's entity event system. This means any constrained live arrays or live values watching `Message` entities (whose constraints match) will react to the event automatically.
298
+
299
+ {% callout title="Subscription lifecycle" %}
300
+ Subscriptions are **demand-driven**. Fetchium only calls `__subscribe` when at least one component or reactive function is reading the entity. When the last observer disconnects (e.g., a component unmounts), the cleanup function returned by `__subscribe` is called immediately. This prevents resource leaks from orphaned WebSocket connections or event listeners.
301
+ {% /callout %}
302
+
303
+ ### Streaming with live data
304
+
305
+ The real power of streaming comes from combining entity subscriptions with live data primitives. Define your result shapes using `t.liveArray` or `t.liveValue`, add a `__subscribe` method to your entity, and the UI stays in sync automatically.
306
+
307
+ ```tsx
308
+ class ChatMessage extends Entity {
309
+ __typename = t.typename('ChatMessage');
310
+ id = t.id;
311
+
312
+ text = t.string;
313
+ channelId = t.string;
314
+ author = t.entity(User);
315
+ createdAt = t.string;
316
+
317
+ __subscribe(onEvent: (event: MutationEvent) => void) {
318
+ const es = new EventSource(`/api/messages/${this.id}/stream`);
319
+
320
+ es.onmessage = (e) => {
321
+ onEvent(JSON.parse(e.data));
322
+ };
323
+
324
+ return () => es.close();
325
+ }
326
+ }
327
+
328
+ class GetMessages extends RESTQuery {
329
+ params = { channelId: t.string };
330
+
331
+ path = `/channels/${this.params.channelId}/messages`;
332
+
333
+ result = {
334
+ messages: t.liveArray(ChatMessage, {
335
+ constraints: { channelId: this.params.channelId },
336
+ sort(a, b) {
337
+ return a.createdAt.localeCompare(b.createdAt);
338
+ },
339
+ }),
340
+ };
341
+ }
342
+ ```
343
+
344
+ When the subscription fires a `create` event for a `ChatMessage` whose `channelId` matches the query's param, the message is automatically inserted into the live array in sorted order. When it fires a `delete` event, the message is removed. Components reading `messages` re-render with the updated list.
345
+
346
+ ```tsx
347
+ import { component } from 'signalium/react';
348
+ import { useQuery } from 'fetchium/react';
349
+
350
+ const ChatRoom = component(({ channelId }: { channelId: string }) => {
351
+ const { messages } = useQuery(GetMessages, { channelId });
352
+
353
+ return (
354
+ <div>
355
+ {messages.map((msg) => (
356
+ <div key={msg.id}>
357
+ <strong>{msg.author.name}</strong>: {msg.text}
358
+ </div>
359
+ ))}
360
+ </div>
361
+ );
362
+ });
363
+ ```
364
+
365
+ No additional wiring is needed. The subscription activates when the component mounts and deactivates when it unmounts.
366
+
367
+ ### Live values with streaming
368
+
369
+ Live values also respond to streaming events. For example, tracking an unread count:
370
+
371
+ ```tsx
372
+ class Channel extends Entity {
373
+ __typename = t.typename('Channel');
374
+ id = t.id;
375
+
376
+ name = t.string;
377
+ unreadCount = t.liveValue(t.number, ChatMessage, {
378
+ constraints: { channelId: this.id },
379
+ onCreate: (count, _msg) => count + 1,
380
+ onUpdate: (count, _msg) => count,
381
+ onDelete: (count, _msg) => count - 1,
382
+ });
383
+ }
384
+ ```
385
+
386
+ When a new `ChatMessage` arrives via the stream for this channel, `unreadCount` increments. When a message is deleted, it decrements. The component reading `channel.unreadCount` re-renders with the new value.
387
+
388
+ ### Channel-level subscriptions
389
+
390
+ In many applications, you want to subscribe to events for an entire collection rather than individual entities. You can implement this by defining `__subscribe` on a parent entity:
391
+
392
+ ```tsx
393
+ class Channel extends Entity {
394
+ __typename = t.typename('Channel');
395
+ id = t.id;
396
+
397
+ name = t.string;
398
+
399
+ __subscribe(onEvent: (event: MutationEvent) => void) {
400
+ const ws = new WebSocket(`ws://api.example.com/channels/${this.id}/events`);
401
+
402
+ ws.onmessage = (e) => {
403
+ // The server sends events for all entity types in this channel
404
+ const event = JSON.parse(e.data);
405
+ onEvent(event);
406
+ };
407
+
408
+ return () => ws.close();
409
+ }
410
+ }
411
+ ```
412
+
413
+ The server can send events for any entity type through a single connection. For example, it might push `ChatMessage` create events, `User` update events (online/offline status), and `Reaction` events all through the same WebSocket. Each event is routed to the appropriate live data based on its `typename`.
414
+
415
+ ---
416
+
417
+ ## Custom Transports
418
+
419
+ You can implement any transport mechanism to deliver real-time updates. The key integration point is `queryClient.applyMutationEvent()`, which injects a `MutationEvent` into the entity event system manually.
420
+
421
+ ### Example: shared WebSocket connection
422
+
423
+ ```tsx
424
+ import { QueryClient } from 'fetchium';
425
+
426
+ const queryClient = new QueryClient();
427
+
428
+ // Single WebSocket for all real-time events
429
+ const ws = new WebSocket('ws://api.example.com/events');
430
+
431
+ ws.onmessage = (e) => {
432
+ const event = JSON.parse(e.data);
433
+
434
+ // Route the event through Fetchium's entity system
435
+ queryClient.applyMutationEvent(event);
436
+ };
437
+ ```
438
+
439
+ This is useful when your application has a single event bus (e.g., one WebSocket connection for the entire app) rather than per-entity subscriptions. Events pushed through `applyMutationEvent` behave identically to events from `__subscribe` or mutations --- they trigger live array updates, live value reducers, and component re-renders.
440
+
441
+ ### Example: Server-Sent Events
442
+
443
+ ```tsx
444
+ const eventSource = new EventSource('/api/events');
445
+
446
+ eventSource.addEventListener('entity-event', (e) => {
447
+ const event = JSON.parse(e.data);
448
+ queryClient.applyMutationEvent(event);
449
+ });
450
+ ```
451
+
452
+ ### Example: Firebase Realtime Database
453
+
454
+ ```tsx
455
+ import { ref, onValue } from 'firebase/database';
456
+
457
+ const messagesRef = ref(db, `channels/${channelId}/messages`);
458
+
459
+ onValue(messagesRef, (snapshot) => {
460
+ const messages = snapshot.val();
461
+
462
+ Object.entries(messages).forEach(([id, data]) => {
463
+ queryClient.applyMutationEvent({
464
+ type: 'update',
465
+ typename: 'ChatMessage',
466
+ data: { id, ...data },
467
+ });
468
+ });
469
+ });
470
+ ```
471
+
472
+ {% callout type="warning" %}
473
+ When using `applyMutationEvent` directly, you are responsible for managing the connection lifecycle (opening, reconnecting, closing). Fetchium does not manage custom transport connections --- it only processes the events you deliver. For managed lifecycle, use [Topic Queries](#topic-queries) or [Entity Subscriptions](#entity-subscriptions) instead.
474
+ {% /callout %}
475
+
476
+ ---
477
+
478
+ ## Subscription Lifecycle
479
+
480
+ Understanding when subscriptions activate and deactivate is important for managing resources and avoiding leaks.
481
+
482
+ ### Activation
483
+
484
+ A subscription activates when:
485
+
486
+ 1. A component mounts and reads an entity that defines `__subscribe`, or a query with `config.subscribe`.
487
+ 2. A reactive function watched by a watcher reads the entity or query.
488
+ 3. A live array or live value that depends on the entity is being observed.
489
+
490
+ Fetchium calls `__subscribe` once per entity instance and `config.subscribe` once per query instance, regardless of how many observers are reading it.
491
+
492
+ ### Deactivation
493
+
494
+ A subscription deactivates when:
495
+
496
+ 1. All components reading the entity or query unmount.
497
+ 2. All watchers observing the entity or query disconnect.
498
+ 3. The entity is evicted from the cache.
499
+
500
+ At that point, Fetchium calls the cleanup function returned by `__subscribe` or `config.subscribe`.
501
+
502
+ ### Reconnection
503
+
504
+ If an entity or query is unobserved and then observed again (e.g., a component remounts), the subscribe function is called again to re-establish the connection. Fetchium does not cache or reuse previous subscriptions.
505
+
506
+ {% callout title="Memory management" %}
507
+ Always return a cleanup function from `__subscribe` and `config.subscribe`. If you open a WebSocket, EventSource, or any other persistent connection, the cleanup function must close it. Failing to do so will leak connections even after the entity is no longer observed.
508
+ {% /callout %}
509
+
510
+ ---
511
+
512
+ ## Combining Patterns
513
+
514
+ In practice, most applications combine multiple real-time strategies:
515
+
516
+ ```tsx
517
+ // Topic-based streaming for live market data
518
+ class GetPrices extends MyTopicQuery {
519
+ topic = 'prices:live';
520
+ result = { prices: t.liveArray(Price) };
521
+ }
522
+
523
+ // Entity-level subscription for individual message updates
524
+ class ChatMessage extends Entity {
525
+ __typename = t.typename('ChatMessage');
526
+ id = t.id;
527
+
528
+ text = t.string;
529
+ channelId = t.string;
530
+
531
+ __subscribe(onEvent) {
532
+ const es = new EventSource(`/api/messages/${this.id}/stream`);
533
+ es.onmessage = (e) => onEvent(JSON.parse(e.data));
534
+ return () => es.close();
535
+ }
536
+ }
537
+
538
+ // Polling for low-priority data
539
+ class GetSystemStatus extends RESTQuery {
540
+ path = '/status';
541
+
542
+ result = t.object({ healthy: t.boolean, activeUsers: t.number });
543
+
544
+ config = {
545
+ subscribe: poll({ interval: 30000 }),
546
+ };
547
+ }
548
+ ```
549
+
550
+ All patterns --- topic queries, entity subscriptions, query subscriptions, and polling --- feed into the same entity event system. Live arrays and live values respond to events regardless of their origin, giving you a unified reactive data layer.