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,270 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Offline & Persistence
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Fetchium has built-in support for offline operation and query persistence. It can detect network status, pause queries when the device goes offline, and persist query results across sessions so your application works even without a connection.
|
|
6
|
+
|
|
7
|
+
This guide covers the offline and persistence-specific pieces of Fetchium's infrastructure. For the caching time knobs (`staleTime`, `gcTime`, `cacheTime`) and cache invalidation patterns, see [Caching & Refetching](/data/caching).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Network Manager
|
|
12
|
+
|
|
13
|
+
The `NetworkManager` tracks whether the device is online or offline. It automatically listens to browser `online` and `offline` events, and exposes the current status as a reactive signal so that queries can pause and resume automatically.
|
|
14
|
+
|
|
15
|
+
### Basic usage
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { NetworkManager } from 'fetchium';
|
|
19
|
+
|
|
20
|
+
const networkManager = new NetworkManager();
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The network manager is passed to the `QueryClient` constructor:
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { QueryClient } from 'fetchium';
|
|
27
|
+
import { SyncQueryStore, MemoryPersistentStore } from 'fetchium/stores/sync';
|
|
28
|
+
|
|
29
|
+
const store = new SyncQueryStore(new MemoryPersistentStore());
|
|
30
|
+
const networkManager = new NetworkManager();
|
|
31
|
+
|
|
32
|
+
const client = new QueryClient(store, { fetch }, networkManager);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If you do not provide a `NetworkManager`, the `QueryClient` creates one automatically.
|
|
36
|
+
|
|
37
|
+
### Manual override
|
|
38
|
+
|
|
39
|
+
For testing or custom scenarios, you can manually override the network status:
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
networkManager.setNetworkStatus(false);
|
|
43
|
+
networkManager.setNetworkStatus(true);
|
|
44
|
+
networkManager.clearManualOverride();
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
When a manual override is active, the browser's actual connectivity events are ignored.
|
|
48
|
+
|
|
49
|
+
### Reactive signal
|
|
50
|
+
|
|
51
|
+
The network manager exposes its status as a reactive signal. You can read it directly in reactive functions:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
const onlineSignal = networkManager.getOnlineSignal();
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Network Modes
|
|
60
|
+
|
|
61
|
+
Each query can configure how it behaves when the device is offline. Set `networkMode` in the query's `config` property:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { t, NetworkMode } from 'fetchium';
|
|
65
|
+
import { RESTQuery } from 'fetchium/rest';
|
|
66
|
+
|
|
67
|
+
class GetUser extends RESTQuery {
|
|
68
|
+
params = { id: t.id };
|
|
69
|
+
|
|
70
|
+
path = `/users/${this.params.id}`;
|
|
71
|
+
|
|
72
|
+
result = { id: t.id, name: t.string };
|
|
73
|
+
|
|
74
|
+
config = {
|
|
75
|
+
networkMode: NetworkMode.OfflineFirst,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
There are three network modes:
|
|
81
|
+
|
|
82
|
+
### `NetworkMode.Online` (default)
|
|
83
|
+
|
|
84
|
+
The query only fetches when the device is online. If the device goes offline while a query is active, the query pauses and resumes automatically when connectivity is restored.
|
|
85
|
+
|
|
86
|
+
This is the safest default --- it prevents failed requests and unnecessary retries while offline.
|
|
87
|
+
|
|
88
|
+
### `NetworkMode.Always`
|
|
89
|
+
|
|
90
|
+
The query fetches regardless of network status. Use this when you have a local server, service worker, or other mechanism that can handle requests even without internet access.
|
|
91
|
+
|
|
92
|
+
### `NetworkMode.OfflineFirst`
|
|
93
|
+
|
|
94
|
+
If cached data exists, the query returns it immediately even when offline. When the device comes back online, the query refetches to get fresh data (assuming the data is stale).
|
|
95
|
+
|
|
96
|
+
This mode is ideal for applications that need to show something to the user even when there is no connection.
|
|
97
|
+
|
|
98
|
+
{% callout %}
|
|
99
|
+
When using `NetworkMode.OfflineFirst`, pair it with a `QueryStore` that persists data across sessions. Otherwise, the cache will be empty on a fresh app launch and there will be nothing to show while offline.
|
|
100
|
+
{% /callout %}
|
|
101
|
+
|
|
102
|
+
### Refetch on reconnect
|
|
103
|
+
|
|
104
|
+
By default, queries with `NetworkMode.Online` or `NetworkMode.OfflineFirst` refetch stale data when the device reconnects. You can disable this behavior:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
config = {
|
|
108
|
+
networkMode: NetworkMode.Online,
|
|
109
|
+
refreshStaleOnReconnect: false,
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Query Stores
|
|
116
|
+
|
|
117
|
+
A query store is responsible for persisting query results and entity data to a durable backend. Fetchium provides two implementations: a synchronous store for in-memory or localStorage-style backends, and an asynchronous store for IndexedDB, AsyncStorage, or cross-worker architectures.
|
|
118
|
+
|
|
119
|
+
### SyncQueryStore
|
|
120
|
+
|
|
121
|
+
The `SyncQueryStore` wraps a synchronous key-value store. It is the simplest option and works well for most applications.
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { SyncQueryStore, MemoryPersistentStore } from 'fetchium/stores/sync';
|
|
125
|
+
|
|
126
|
+
const store = new SyncQueryStore(new MemoryPersistentStore());
|
|
127
|
+
const client = new QueryClient(store, { fetch });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The `MemoryPersistentStore` keeps everything in memory --- data is lost when the page is refreshed. For persistence across sessions, implement the `SyncPersistentStore` interface with a durable backend like `localStorage`.
|
|
131
|
+
|
|
132
|
+
### AsyncQueryStore
|
|
133
|
+
|
|
134
|
+
The `AsyncQueryStore` is designed for asynchronous storage backends such as IndexedDB or React Native's AsyncStorage. It uses a writer-reader architecture where one instance (the writer) owns the backing store, and other instances (readers) communicate with it via messages.
|
|
135
|
+
|
|
136
|
+
```tsx
|
|
137
|
+
import { AsyncQueryStore } from 'fetchium/stores/async';
|
|
138
|
+
|
|
139
|
+
const store = new AsyncQueryStore({
|
|
140
|
+
isWriter: true,
|
|
141
|
+
connect: (handleMessage) => ({
|
|
142
|
+
sendMessage: (msg) => handleMessage(msg),
|
|
143
|
+
}),
|
|
144
|
+
delegate: myAsyncPersistentStore,
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Writer vs reader:**
|
|
149
|
+
|
|
150
|
+
- The **writer** (`isWriter: true`) is the only instance that writes to the backing store. It must be provided a `delegate` (an `AsyncPersistentStore` implementation).
|
|
151
|
+
- **Readers** (`isWriter: false`) send write operations to the writer via messages and can load data directly from their own delegate (if provided).
|
|
152
|
+
|
|
153
|
+
This architecture ensures serialized writes even when multiple tabs or workers are involved.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Custom Persistent Stores
|
|
158
|
+
|
|
159
|
+
To build a custom persistence backend, implement either the `SyncPersistentStore` or `AsyncPersistentStore` interface. Both share the same shape --- the async version wraps each method in a `Promise`.
|
|
160
|
+
|
|
161
|
+
The store needs to handle three data types: strings (for serialized JSON values), numbers (for timestamps and reference counts), and `Uint32Array` buffers (for entity ID sets and LRU queues).
|
|
162
|
+
|
|
163
|
+
### Example: localStorage adapter
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
class LocalStoragePersistentStore implements SyncPersistentStore {
|
|
167
|
+
has(key: string): boolean {
|
|
168
|
+
return localStorage.getItem(key) !== null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
getString(key: string): string | undefined {
|
|
172
|
+
return localStorage.getItem(key) ?? undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setString(key: string, value: string): void {
|
|
176
|
+
localStorage.setItem(key, value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
getNumber(key: string): number | undefined {
|
|
180
|
+
const v = localStorage.getItem(key);
|
|
181
|
+
return v !== null ? Number(v) : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setNumber(key: string, value: number): void {
|
|
185
|
+
localStorage.setItem(key, String(value));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getBuffer(key: string): Uint32Array | undefined {
|
|
189
|
+
const v = localStorage.getItem(key);
|
|
190
|
+
if (v === null) return undefined;
|
|
191
|
+
return new Uint32Array(JSON.parse(v));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setBuffer(key: string, value: Uint32Array): void {
|
|
195
|
+
localStorage.setItem(key, JSON.stringify(Array.from(value)));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
delete(key: string): void {
|
|
199
|
+
localStorage.removeItem(key);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
getAllKeys(): string[] {
|
|
203
|
+
return Object.keys(localStorage);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
{% callout type="warning" %}
|
|
209
|
+
`localStorage` has a 5 MB limit in most browsers. For larger datasets, consider using IndexedDB via the `AsyncQueryStore` instead.
|
|
210
|
+
{% /callout %}
|
|
211
|
+
|
|
212
|
+
For complete API details on both store types, see the [stores/sync](/api/stores-sync) and [stores/async](/api/stores-async) API reference pages.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Putting It All Together
|
|
217
|
+
|
|
218
|
+
Here is a complete example that sets up a `QueryClient` with persistence and network awareness for an offline-capable application:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
import { QueryClient, NetworkManager } from 'fetchium';
|
|
222
|
+
import { SyncQueryStore } from 'fetchium/stores/sync';
|
|
223
|
+
|
|
224
|
+
const store = new SyncQueryStore(new LocalStoragePersistentStore());
|
|
225
|
+
const networkManager = new NetworkManager();
|
|
226
|
+
|
|
227
|
+
const client = new QueryClient(
|
|
228
|
+
store,
|
|
229
|
+
{
|
|
230
|
+
fetch: globalThis.fetch,
|
|
231
|
+
baseUrl: 'https://api.example.com',
|
|
232
|
+
},
|
|
233
|
+
networkManager,
|
|
234
|
+
);
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
With this setup:
|
|
238
|
+
|
|
239
|
+
- Query results are persisted to `localStorage` and survive page refreshes
|
|
240
|
+
- Queries automatically pause when the device goes offline and resume when it reconnects
|
|
241
|
+
- Unused queries are evicted from memory after their `gcTime` expires, but their persisted data remains in `localStorage` for the next session
|
|
242
|
+
|
|
243
|
+
Configure individual queries for offline behavior:
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
class GetDashboard extends RESTQuery {
|
|
247
|
+
path = '/dashboard';
|
|
248
|
+
|
|
249
|
+
result = { stats: t.object({ visits: t.number }) };
|
|
250
|
+
|
|
251
|
+
config = {
|
|
252
|
+
networkMode: NetworkMode.OfflineFirst,
|
|
253
|
+
staleTime: 60_000,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Next Steps
|
|
261
|
+
|
|
262
|
+
{% quick-links %}
|
|
263
|
+
|
|
264
|
+
{% quick-link title="Caching & Refetching" icon="presets" href="/data/caching" description="Understand staleTime, gcTime, cacheTime, and cache invalidation patterns" /%}
|
|
265
|
+
|
|
266
|
+
{% quick-link title="stores/sync API" icon="plugins" href="/api/stores-sync" description="Full API reference for the synchronous query store" /%}
|
|
267
|
+
|
|
268
|
+
{% quick-link title="stores/async API" icon="theming" href="/api/stores-async" description="Full API reference for the asynchronous query store" /%}
|
|
269
|
+
|
|
270
|
+
{% /quick-links %}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Testing
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Fetchium ships a dedicated `fetchium/testing` package that gives you type-safe query mocking, automatic data generation from your type definitions, entity factories, and test variation knobs for exploring structural edge cases. You never need to understand Signalium internals, mock global state, or patch module imports. Just set up your mocks, write your tests, and let Fetchium handle the wiring.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
Every test starts with a `MockClient`. It wraps a `QueryClient` with an in-memory store and a mock fetch router so your queries resolve against canned data instead of a real server.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { MockClient } from 'fetchium/testing';
|
|
15
|
+
|
|
16
|
+
const mock = new MockClient();
|
|
17
|
+
afterEach(() => mock.reset());
|
|
18
|
+
afterAll(() => mock.destroy());
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`mock.reset()` clears all mocked routes and request history between tests. `mock.destroy()` tears down the underlying client and timers.
|
|
22
|
+
|
|
23
|
+
{% callout title="Always destroy the client" type="warning" %}
|
|
24
|
+
`QueryClient` manages background timers. Call `mock.destroy()` in your test teardown to avoid timer leaks.
|
|
25
|
+
{% /callout %}
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Setting Up Your Test Harness
|
|
30
|
+
|
|
31
|
+
`MockClient` provides a `QueryClient` via `mock.client`. You plug this into your own render utility alongside your app's providers --- auth, theme, i18n, routing, or whatever your app needs:
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
// test-utils.tsx
|
|
35
|
+
import { render } from '@testing-library/react';
|
|
36
|
+
import { ContextProvider } from 'signalium/react';
|
|
37
|
+
import { QueryClientContext } from 'fetchium';
|
|
38
|
+
|
|
39
|
+
export function renderApp(
|
|
40
|
+
ui: React.ReactElement,
|
|
41
|
+
{ client }: { client: QueryClient },
|
|
42
|
+
) {
|
|
43
|
+
return render(
|
|
44
|
+
<ContextProvider value={client} context={QueryClientContext}>
|
|
45
|
+
{ui}
|
|
46
|
+
</ContextProvider>,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If your app has additional providers, add them here. Fetchium has no opinions about your provider stack --- it only needs `QueryClientContext` to be present.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Mocking Queries
|
|
56
|
+
|
|
57
|
+
Register mock responses with `mock.when()`. The response shape is type-checked against the query's `result` definition:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { Entity, t } from 'fetchium';
|
|
61
|
+
import { RESTQuery } from 'fetchium/rest';
|
|
62
|
+
|
|
63
|
+
class User extends Entity {
|
|
64
|
+
__typename = t.typename('User');
|
|
65
|
+
id = t.id;
|
|
66
|
+
name = t.string;
|
|
67
|
+
email = t.string;
|
|
68
|
+
avatar = t.optional(t.string);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class GetUser extends RESTQuery {
|
|
72
|
+
params = { id: t.number };
|
|
73
|
+
path = `/users/${this.params.id}`;
|
|
74
|
+
result = { user: t.entity(User) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// In your test:
|
|
78
|
+
mock.when(GetUser, { id: 1 }).respond({
|
|
79
|
+
user: mock.entity(User, { name: 'Alice', email: 'alice@example.com' }),
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Auto-generated responses
|
|
84
|
+
|
|
85
|
+
If you do not care about the exact field values, `.auto()` generates a complete valid response from the query's type definitions:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
mock.when(GetUser, { id: 1 }).auto();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
You can pass partial overrides that are deep-merged into the generated data:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
mock.when(GetUser, { id: 1 }).auto({
|
|
95
|
+
user: { name: 'Alice' },
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Catch-all routes
|
|
100
|
+
|
|
101
|
+
Omit params to match any request for that query class:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
mock.when(GetUser).auto();
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Sequential responses
|
|
108
|
+
|
|
109
|
+
Chain `.thenRespond()` to queue multiple responses:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
mock
|
|
113
|
+
.when(GetUser, { id: 1 })
|
|
114
|
+
.respond({ user: mock.entity(User, { name: 'V1' }) })
|
|
115
|
+
.thenRespond({ user: mock.entity(User, { name: 'V2' }) });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The first fetch returns V1, the second returns V2. Subsequent fetches reuse the last response.
|
|
119
|
+
|
|
120
|
+
### Error states
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
mock.when(GetUser, { id: 999 }).error(404);
|
|
124
|
+
mock.when(GetUser, { id: 1 }).networkError('connection refused');
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Simulated latency
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
mock.when(GetUser, { id: 1 }).delay(500).auto();
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Raw escape hatch
|
|
134
|
+
|
|
135
|
+
`.raw()` bypasses type checking entirely, for testing how your app handles unexpected data:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
mock.when(GetUser, { id: 1 }).raw({ incomplete: true });
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Generating Test Data
|
|
144
|
+
|
|
145
|
+
### `mock.entity()`
|
|
146
|
+
|
|
147
|
+
Generates a plain JSON object matching an entity's type definition. Fields are auto-filled with debuggable sequential values (`"name_1"`, `"email_2"`, etc.), and you override whatever matters for your test:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
const alice = mock.entity(User, { name: 'Alice' });
|
|
151
|
+
// => { __typename: 'User', id: '1', name: 'Alice', email: 'email_1' }
|
|
152
|
+
|
|
153
|
+
const bob = mock.entity(User, { name: 'Bob', email: 'bob@test.com' });
|
|
154
|
+
// => { __typename: 'User', id: '2', name: 'Bob', email: 'bob@test.com' }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
IDs auto-increment per typename, so each entity gets a unique identity.
|
|
158
|
+
|
|
159
|
+
### Entity Factories
|
|
160
|
+
|
|
161
|
+
For richer test data, register a factory with custom generators:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
mock.define(User, {
|
|
165
|
+
name: (seq) => `User ${seq}`,
|
|
166
|
+
email: (seq, fields) =>
|
|
167
|
+
`${fields.name.toLowerCase().replace(' ', '.')}@test.com`,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const alice = mock.entity(User, { name: 'Alice' });
|
|
171
|
+
// => { __typename: 'User', id: '1', name: 'Alice', email: 'alice@test.com' }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Generator functions receive a `seq` counter (auto-incrementing per typename) and a `fields` object containing previously generated field values, for derived data.
|
|
175
|
+
|
|
176
|
+
Factories are also used by `.auto()` when it generates entities for query responses.
|
|
177
|
+
|
|
178
|
+
### Standalone usage
|
|
179
|
+
|
|
180
|
+
For Storybook or other contexts outside `MockClient`:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { entity, defineFactory } from 'fetchium/testing';
|
|
184
|
+
|
|
185
|
+
const user = entity(User, { name: 'Alice' });
|
|
186
|
+
|
|
187
|
+
const UserFactory = defineFactory(User, {
|
|
188
|
+
name: (seq) => `User ${seq}`,
|
|
189
|
+
});
|
|
190
|
+
UserFactory.build({ name: 'Alice' });
|
|
191
|
+
UserFactory.buildMany(5);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Testing Components
|
|
197
|
+
|
|
198
|
+
A complete component test:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { MockClient } from 'fetchium/testing';
|
|
202
|
+
import { renderApp } from '../test-utils';
|
|
203
|
+
|
|
204
|
+
const mock = new MockClient();
|
|
205
|
+
afterEach(() => mock.reset());
|
|
206
|
+
afterAll(() => mock.destroy());
|
|
207
|
+
|
|
208
|
+
it('renders the user name after loading', async () => {
|
|
209
|
+
mock.when(GetUser, { id: 1 }).respond({
|
|
210
|
+
user: mock.entity(User, { name: 'Alice', email: 'alice@example.com' }),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const { getByTestId } = renderApp(<UserProfile userId={1} />, {
|
|
214
|
+
client: mock.client,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(getByTestId('name')).toHaveTextContent('Alice');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Testing Mutations
|
|
226
|
+
|
|
227
|
+
Mutations are mocked the same way. You can inspect what was sent using `mock.calls`:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import { getMutation, t } from 'fetchium';
|
|
231
|
+
import { RESTMutation } from 'fetchium/rest';
|
|
232
|
+
|
|
233
|
+
class CreateUser extends RESTMutation {
|
|
234
|
+
readonly params = { name: t.string, email: t.string };
|
|
235
|
+
readonly path = '/users';
|
|
236
|
+
readonly method = 'POST' as const;
|
|
237
|
+
readonly body = { name: this.params.name, email: this.params.email };
|
|
238
|
+
readonly result = { user: t.entity(User) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
it('sends the correct request body', async () => {
|
|
242
|
+
mock.when(CreateUser).respond({
|
|
243
|
+
user: mock.entity(User, { name: 'Bob', email: 'bob@example.com' }),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ... trigger the mutation in your component or reactive code ...
|
|
247
|
+
|
|
248
|
+
expect(mock.wasCalled(CreateUser)).toBe(true);
|
|
249
|
+
expect(mock.lastCall(CreateUser)?.body).toEqual({
|
|
250
|
+
name: 'Bob',
|
|
251
|
+
email: 'bob@example.com',
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### Entity effects
|
|
257
|
+
|
|
258
|
+
Because Fetchium normalizes entities, a mutation that returns updated entity data automatically updates any query that references the same entity:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
it('mutation updates the entity visible in queries', async () => {
|
|
262
|
+
mock.when(GetUser, { id: 1 }).respond({
|
|
263
|
+
user: mock.entity(User, { id: '1', name: 'Alice' }),
|
|
264
|
+
});
|
|
265
|
+
mock.when(UpdateUser).respond({
|
|
266
|
+
user: mock.entity(User, { id: '1', name: 'Alice Updated' }),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Fetch the user, then mutate.
|
|
270
|
+
// The query's data reflects the update automatically.
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Summary
|
|
277
|
+
|
|
278
|
+
| What you are testing | Tools needed | Pattern |
|
|
279
|
+
| -------------------------------- | ------------------------------- | --------------------------------------------------- |
|
|
280
|
+
| React components with `useQuery` | `MockClient` + your `renderApp` | `mock.when().respond()`, render, assert |
|
|
281
|
+
| Mutations | `MockClient` | `mock.when(Mutation).respond()`, check `mock.calls` |
|
|
282
|
+
| Entity effects after mutation | `MockClient` + entity query | Mock both, verify query data reflects mutation |
|
|
283
|
+
| Error states | `MockClient` | `.error()` or `.networkError()` |
|
|
284
|
+
|
|
285
|
+
The core idea is always the same: create a `MockClient`, set up your mocks, plug `mock.client` into your providers, and assert.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Next Steps
|
|
290
|
+
|
|
291
|
+
{% quick-links %}
|
|
292
|
+
|
|
293
|
+
{% quick-link title="Queries" icon="plugins" href="/core/queries" description="Learn how to define queries and use them in components" /%}
|
|
294
|
+
|
|
295
|
+
{% quick-link title="Entities" icon="theming" href="/core/entities" description="Understand normalized entities and identity-stable proxies" /%}
|
|
296
|
+
|
|
297
|
+
{% quick-link title="Mutations" icon="presets" href="/data/mutations" description="Define mutations with entity effects and optimistic updates" /%}
|
|
298
|
+
|
|
299
|
+
{% quick-link title="Error Handling" icon="warning" href="/guides/error-handling" description="Handle network failures, retries, and error boundaries" /%}
|
|
300
|
+
|
|
301
|
+
{% /quick-links %}
|