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.
- package/CHANGELOG.md +6 -5
- package/dist/cjs/development/QueryClient-CLi3ONNM.js +2 -0
- package/dist/cjs/development/QueryClient-CLi3ONNM.js.map +1 -0
- package/dist/cjs/development/QueryController-BQA49OYU.js +2 -0
- package/dist/cjs/development/QueryController-BQA49OYU.js.map +1 -0
- package/dist/cjs/development/index.js +1 -1
- package/dist/cjs/development/index.js.map +1 -1
- package/dist/cjs/development/mutation-CikIl_6k.js +2 -0
- package/dist/cjs/development/mutation-CikIl_6k.js.map +1 -0
- package/dist/cjs/development/react/index.js +1 -1
- package/dist/cjs/development/rest/index.js +2 -0
- package/dist/cjs/development/rest/index.js.map +1 -0
- package/dist/cjs/development/topic/index.js +2 -0
- package/dist/cjs/development/topic/index.js.map +1 -0
- package/dist/cjs/production/QueryClient-N0MJmuHW.js +2 -0
- package/dist/cjs/production/QueryClient-N0MJmuHW.js.map +1 -0
- package/dist/cjs/production/QueryController-BQA49OYU.js +2 -0
- package/dist/cjs/production/QueryController-BQA49OYU.js.map +1 -0
- package/dist/cjs/production/index.js +1 -1
- package/dist/cjs/production/index.js.map +1 -1
- package/dist/cjs/production/mutation-P_Yb4LI9.js +2 -0
- package/dist/cjs/production/mutation-P_Yb4LI9.js.map +1 -0
- package/dist/cjs/production/react/index.js +1 -1
- package/dist/cjs/production/rest/index.js +2 -0
- package/dist/cjs/production/rest/index.js.map +1 -0
- package/dist/cjs/production/topic/index.js +2 -0
- package/dist/cjs/production/topic/index.js.map +1 -0
- package/dist/esm/MutationResult.d.ts +0 -1
- package/dist/esm/MutationResult.d.ts.map +1 -1
- package/dist/esm/QueryClient.d.ts +26 -4
- package/dist/esm/QueryClient.d.ts.map +1 -1
- package/dist/esm/QueryController.d.ts +49 -0
- package/dist/esm/QueryController.d.ts.map +1 -0
- package/dist/esm/QueryResult.d.ts +10 -10
- package/dist/esm/QueryResult.d.ts.map +1 -1
- package/dist/esm/development/QueryClient-Dtde3pss.js +2572 -0
- package/dist/esm/development/QueryClient-Dtde3pss.js.map +1 -0
- package/dist/esm/development/QueryController-Ch_ncxiI.js +14 -0
- package/dist/esm/development/QueryController-Ch_ncxiI.js.map +1 -0
- package/dist/esm/development/index.js +29 -100
- package/dist/esm/development/index.js.map +1 -1
- package/dist/esm/development/mutation-UZshUQAf.js +58 -0
- package/dist/esm/development/mutation-UZshUQAf.js.map +1 -0
- package/dist/esm/development/react/index.js +1 -1
- package/dist/esm/development/rest/index.js +142 -0
- package/dist/esm/development/rest/index.js.map +1 -0
- package/dist/esm/development/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
- package/dist/esm/development/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
- package/dist/esm/development/stores/async.js +6 -6
- package/dist/esm/development/stores/sync.js +5 -5
- package/dist/esm/development/topic/index.js +86 -0
- package/dist/esm/development/topic/index.js.map +1 -0
- package/dist/esm/index.d.ts +5 -4
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/mutation.d.ts +6 -19
- package/dist/esm/mutation.d.ts.map +1 -1
- package/dist/esm/production/{QueryClient-BP0Z1rQV.js → QueryClient-YqnBxFy1.js} +972 -968
- package/dist/esm/production/QueryClient-YqnBxFy1.js.map +1 -0
- package/dist/esm/production/QueryController-Ch_ncxiI.js +14 -0
- package/dist/esm/production/QueryController-Ch_ncxiI.js.map +1 -0
- package/dist/esm/production/index.js +29 -100
- package/dist/esm/production/index.js.map +1 -1
- package/dist/esm/production/mutation-pgFl1uIY.js +58 -0
- package/dist/esm/production/mutation-pgFl1uIY.js.map +1 -0
- package/dist/esm/production/react/index.js +1 -1
- package/dist/esm/production/rest/index.js +142 -0
- package/dist/esm/production/rest/index.js.map +1 -0
- package/dist/esm/production/{shared-Dq2yW78d.js → shared-DcuVH8Pf.js} +5 -5
- package/dist/esm/production/{shared-Dq2yW78d.js.map → shared-DcuVH8Pf.js.map} +1 -1
- package/dist/esm/production/stores/async.js +6 -6
- package/dist/esm/production/stores/sync.js +5 -5
- package/dist/esm/production/topic/index.js +86 -0
- package/dist/esm/production/topic/index.js.map +1 -0
- package/dist/esm/query-types.d.ts +2 -4
- package/dist/esm/query-types.d.ts.map +1 -1
- package/dist/esm/query.d.ts +17 -39
- package/dist/esm/query.d.ts.map +1 -1
- package/dist/esm/rest/RESTMutation.d.ts +18 -0
- package/dist/esm/rest/RESTMutation.d.ts.map +1 -0
- package/dist/esm/rest/RESTQuery.d.ts +24 -0
- package/dist/esm/rest/RESTQuery.d.ts.map +1 -0
- package/dist/esm/rest/RESTQueryController.d.ts +34 -0
- package/dist/esm/rest/RESTQueryController.d.ts.map +1 -0
- package/dist/esm/rest/index.d.ts +5 -0
- package/dist/esm/rest/index.d.ts.map +1 -0
- package/dist/esm/stores/shared.d.ts.map +1 -1
- package/dist/esm/testing/MockClient.d.ts +64 -0
- package/dist/esm/testing/MockClient.d.ts.map +1 -0
- package/dist/esm/testing/auto-generate.d.ts +20 -0
- package/dist/esm/testing/auto-generate.d.ts.map +1 -0
- package/dist/esm/testing/entity-factory.d.ts +13 -0
- package/dist/esm/testing/entity-factory.d.ts.map +1 -0
- package/dist/esm/testing/index.d.ts +6 -0
- package/dist/esm/testing/index.d.ts.map +1 -0
- package/dist/esm/testing/types.d.ts +37 -0
- package/dist/esm/testing/types.d.ts.map +1 -0
- package/dist/esm/topic/TopicQuery.d.ts +10 -0
- package/dist/esm/topic/TopicQuery.d.ts.map +1 -0
- package/dist/esm/topic/TopicQueryController.d.ts +43 -0
- package/dist/esm/topic/TopicQueryController.d.ts.map +1 -0
- package/dist/esm/topic/index.d.ts +3 -0
- package/dist/esm/topic/index.d.ts.map +1 -0
- package/dist/esm/typeDefs.d.ts +1 -1
- package/dist/esm/types.d.ts +9 -4
- package/dist/esm/types.d.ts.map +1 -1
- package/package.json +51 -4
- package/plugin/.claude-plugin/plugin.json +10 -0
- package/plugin/agents/fetchium.md +168 -0
- package/plugin/docs/api/fetchium-react.md +135 -0
- package/plugin/docs/api/fetchium.md +674 -0
- package/plugin/docs/api/stores-async.md +219 -0
- package/plugin/docs/api/stores-sync.md +133 -0
- package/plugin/docs/core/entities.md +351 -0
- package/plugin/docs/core/queries.md +600 -0
- package/plugin/docs/core/streaming.md +550 -0
- package/plugin/docs/core/types.md +374 -0
- package/plugin/docs/data/caching.md +298 -0
- package/plugin/docs/data/live-data.md +435 -0
- package/plugin/docs/data/mutations.md +465 -0
- package/plugin/docs/guides/auth.md +318 -0
- package/plugin/docs/guides/error-handling.md +351 -0
- package/plugin/docs/guides/offline.md +270 -0
- package/plugin/docs/guides/testing.md +301 -0
- package/plugin/docs/quickstart.md +170 -0
- package/plugin/docs/reference/pagination.md +519 -0
- package/plugin/docs/reference/rest-queries.md +107 -0
- package/plugin/docs/reference/why-signalium.md +364 -0
- package/plugin/docs/setup/project-setup.md +319 -0
- package/plugin/install.mjs +88 -0
- package/plugin/skills/design/SKILL.md +140 -0
- package/plugin/skills/teach/SKILL.md +105 -0
- package/dist/cjs/development/QueryClient-CpmwggOn.js +0 -2
- package/dist/cjs/development/QueryClient-CpmwggOn.js.map +0 -1
- package/dist/cjs/production/QueryClient-qi3bR0eD.js +0 -2
- package/dist/cjs/production/QueryClient-qi3bR0eD.js.map +0 -1
- package/dist/esm/development/QueryClient-DRZtPKFD.js +0 -2568
- package/dist/esm/development/QueryClient-DRZtPKFD.js.map +0 -1
- 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.
|