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,600 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Queries
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Fetchium is founded on the well-established Query-Mutation split-paradigm of modern data fetching libraries, wherein:
|
|
6
|
+
|
|
7
|
+
- **Queries** are parameterized requests to _read_ data without changing its state
|
|
8
|
+
- **Mutations** are parameterized requests to _change the state_ of that data
|
|
9
|
+
|
|
10
|
+
This distinction is not about HTTP methods --- it's about _usage patterns_. From the frontend's perspective, the key differences are:
|
|
11
|
+
|
|
12
|
+
1. **Queries are automatic and cached.** They run when you access them, their results are cached and deduplicated, and they refetch automatically when they become stale. If a user visits a page that needs data, a query fetches it.
|
|
13
|
+
2. **Mutations are manual and ephemeral.** They only run when you explicitly call `.run()`, their results are not cached, and they never run automatically. A mutation fires in response to a _user action_ --- clicking a button, submitting a form, confirming a dialog.
|
|
14
|
+
|
|
15
|
+
This is a more fundamental split than GET vs POST. In REST, a `POST` endpoint that returns data in a read-only, cacheable way (e.g. a complex search with a request body, or an endpoint that uses `POST` for legacy reasons) is still a _Query_ from Fetchium's perspective --- it should be modeled with `RESTQuery` and `method = 'POST'`, because you want caching, deduplication, and automatic refetching. Conversely, a `DELETE` that removes a resource is a _Mutation_, even though the endpoint might return the deleted resource.
|
|
16
|
+
|
|
17
|
+
The rule of thumb: **if a user visits a page and needs to see data, that's a query. If a user takes an action that changes data, that's a mutation.**
|
|
18
|
+
|
|
19
|
+
This pattern works across many different protocols:
|
|
20
|
+
|
|
21
|
+
- **REST APIs** --- most GET requests are queries, most POST/PUT/DELETE are mutations, but not always (complex searches via POST are queries)
|
|
22
|
+
- **GraphQL** --- has this exact split built into the language (`query` vs `mutation`)
|
|
23
|
+
- **JSON-RPC** and **gRPC** --- have no formal distinction, but the query/mutation pattern maps cleanly onto read vs write operations
|
|
24
|
+
|
|
25
|
+
Fetchium ships built-in adapters for JSON REST APIs (`RESTQuery` / `RESTMutation` from `fetchium/rest`) and topic-based streaming (`TopicQuery` from `fetchium/topic`). REST is the lowest common denominator across the web ecosystem, while TopicQuery provides a declarative way to integrate with message buses, WebSockets, and other pub/sub systems --- see [Streaming](/core/streaming) for details. Fetchium is built from the ground up to support _any_ protocol with a simple, easily expandable class-based adapter system --- see [Custom Queries](#custom-queries) and [Custom Mutations](/data/mutations#custom-mutations).
|
|
26
|
+
|
|
27
|
+
More importantly, Fetchium handles caching, deduplication, and refetching behind the scenes. And when your results include [Entities](/core/entities) and [Live Data](/data/live-data), Fetchium also handles normalization and incremental streaming updates.
|
|
28
|
+
|
|
29
|
+
## Defining a Query
|
|
30
|
+
|
|
31
|
+
All queries fundamentally require two user defined fields: _params_ and _result_.
|
|
32
|
+
|
|
33
|
+
- `params` defines what the caller must provide.
|
|
34
|
+
- `result` defines the response shape that is returned.
|
|
35
|
+
|
|
36
|
+
These are defined using a type-validation-DSL, `t`, which is discussed more in the next section. For now, we'll only use simple type validators like `t.number` or `t.string` which are self-explanatory.
|
|
37
|
+
|
|
38
|
+
Here is a basic example using RESTQuery.
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { t } from 'fetchium';
|
|
42
|
+
import { RESTQuery } from 'fetchium/rest';
|
|
43
|
+
|
|
44
|
+
class GetUser extends RESTQuery {
|
|
45
|
+
params = {
|
|
46
|
+
id: t.number,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
path = `/users/${this.params.id}`;
|
|
50
|
+
|
|
51
|
+
result = {
|
|
52
|
+
name: t.string,
|
|
53
|
+
email: t.string,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
In this query definition, you can see the `params` and `result` being constructed with the `t` type DSL. You can also see `path`, which is a REST-specific field, and which references `this.params.id` in the interpolation.
|
|
59
|
+
|
|
60
|
+
This might seem a bit odd to you, since presumably `t.number` is not the _actual_ ID of the user we're attempting to fetch - it's a type definition. So, what is going on here?
|
|
61
|
+
|
|
62
|
+
The reality is that `Query` classes are not _normal_ classes - they are _templates_. Users are never meant to create one like a normal class (e.g. `new GetUser(params)`) Instead, Fetchium creates a single instance of the class to capture the expected parameter and result types, and to capture the _parameterized_ versions of the other fields.
|
|
63
|
+
|
|
64
|
+
In this way, parameters can be passed in a _typesafe manner_ to any other field in the class:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { t } from 'fetchium';
|
|
68
|
+
import { RESTQuery } from 'fetchium/rest';
|
|
69
|
+
|
|
70
|
+
class GetUser extends RESTQuery {
|
|
71
|
+
params = {
|
|
72
|
+
id: t.number,
|
|
73
|
+
includeTags: t.boolean,
|
|
74
|
+
apiKey: t.string,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
path = `/users/${this.params.id}`;
|
|
78
|
+
|
|
79
|
+
searchParams = {
|
|
80
|
+
includeTags: this.params.includeTags,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
headers = {
|
|
84
|
+
'X-API-KEY': this.params.apiKey,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
result = {
|
|
88
|
+
name: t.string,
|
|
89
|
+
email: t.string,
|
|
90
|
+
tags: t.optional(t.array(t.string)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The full list of options provided by `RESTQuery` is available below, but there are two main reasons for this separation of concerns:
|
|
96
|
+
|
|
97
|
+
1. Parameters should not be connected to the _specifics_ of your Queries. You should be able to change if a parameter is passed as a search param, a path param, a header, or so on, without having to change every _usage_ of the query.
|
|
98
|
+
2. More broadly, this distinction allows you to keep your Queries _protocol agnostic_. If you decide to switch from REST to GraphQL or gRPC in the future, none of your usage sites need to change.
|
|
99
|
+
|
|
100
|
+
The same distinction applies to query _results_. Internally, `RESTQuery` exposes the raw HTTP response on `this.response` after each fetch completes (which can be used in `getConfig()` for things like controlling retry or polling behavior based on whether the response was successful or errored). Externally, the result gets parsed by the `this.result` definition and exposed as the query's value.
|
|
101
|
+
|
|
102
|
+
This brings us to the next topic: Using Queries.
|
|
103
|
+
|
|
104
|
+
## Query Usage
|
|
105
|
+
|
|
106
|
+
Fetchium is built on [Signalium](/reference/why-signalium), which is a framework-agnostic signal-based reactivity framework. As such, it supports usage with _any_ JavaScript framework and in any context. Fetchium can be used on clients and servers, in Vue or Svelte or Angular, and so on.
|
|
107
|
+
|
|
108
|
+
That said, Fetchium is primarily focused on _client-side_ data fetching, and **React** support is built-in as it is the most commonly used JavaScript framework today. There are two main ways that Fetchium can be used in React: With Hooks, or with Signalium.
|
|
109
|
+
|
|
110
|
+
### Usage with React Hooks
|
|
111
|
+
|
|
112
|
+
For Hooks usage, you can use `useQuery` to fetch a query:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { useQuery } from 'fetchium/react';
|
|
116
|
+
|
|
117
|
+
export function UserProfile() {
|
|
118
|
+
const result = useQuery(GetUser, { id: 42 });
|
|
119
|
+
|
|
120
|
+
if (result.isRejected) return <div>Error: {result.error.message}</div>;
|
|
121
|
+
if (!result.isReady) return <div>Loading...</div>;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div>
|
|
125
|
+
<h1>{result.value.name}</h1>
|
|
126
|
+
<p>{result.value.email}</p>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
This works like any other Hook and can be a drop-in replacement for most other query libraries (aside from differences in query definitions). Queries return a _reactive promise_, which is a formalized data structure for making any asynchronous action reactive:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
interface ReactivePromise<T> {
|
|
136
|
+
// The current value of the promise
|
|
137
|
+
value: T | undefined;
|
|
138
|
+
|
|
139
|
+
// Whether or not the promise has loaded a value
|
|
140
|
+
// at least once. Use this for type narrowing on `value`
|
|
141
|
+
isReady: boolean;
|
|
142
|
+
|
|
143
|
+
// Whether or not the promise is currently loading.
|
|
144
|
+
// Will be true if the promise reloads for any reason,
|
|
145
|
+
// even if a value already exists, so isReady should be
|
|
146
|
+
// preferred unless you want to sync in the background.
|
|
147
|
+
isPending: boolean;
|
|
148
|
+
|
|
149
|
+
// Whether or not the promise resolved on its most
|
|
150
|
+
// recent execution.
|
|
151
|
+
isResolved: boolean;
|
|
152
|
+
|
|
153
|
+
// Whether or not the promise rejected on its most
|
|
154
|
+
// recent execution.
|
|
155
|
+
isRejected: boolean;
|
|
156
|
+
|
|
157
|
+
// If the promise rejected, the error that it rejected with.
|
|
158
|
+
error: unknown;
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`useQuery` returns a `ReactivePromise<QueryResult>`, which is the _result_ of the query and some additional properties:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
type QueryResult<Q extends Query> = Q['result'] & {
|
|
166
|
+
__refetch(): Promise<Q['result']>;
|
|
167
|
+
__fetchNext(): Promise<Q['result']>;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
declare function useQuery<Q extends Query>(
|
|
171
|
+
query: Q,
|
|
172
|
+
params: Q['params'],
|
|
173
|
+
opts?: {
|
|
174
|
+
suspended?: boolean;
|
|
175
|
+
},
|
|
176
|
+
): ReactivePromise<QueryResult<Q>>;
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The reason `__refetch` and `__fetchNext` are defined on the _result_ of the query and not the `ReactivePromise` is about composability, which leads us into usage within Signalium.
|
|
180
|
+
|
|
181
|
+
### Usage with React + Signalium
|
|
182
|
+
|
|
183
|
+
Fetchium can be used entirely without Signalium. If you want to integrate Fetchium into an existing React app and just want to keep your existing Hooks and state management, that will _always_ be supported as a first-class citizen.
|
|
184
|
+
|
|
185
|
+
However, Signalium provides some DX and performance benefits over Hooks. For instance, it's a fairly common to need to chain together two different requests in sequence:
|
|
186
|
+
|
|
187
|
+
```tsx
|
|
188
|
+
import { useQuery } from 'fetchium/react';
|
|
189
|
+
import { GetCurrentUser, GetUserProfile } from './queries';
|
|
190
|
+
|
|
191
|
+
export function UserProfile() {
|
|
192
|
+
const userResult = useQuery(GetCurrentUser);
|
|
193
|
+
const userProfileResult = useQuery(
|
|
194
|
+
GetUserProfile,
|
|
195
|
+
{ user },
|
|
196
|
+
{ suspended: !user },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (userResult.isRejected || userProfileResult.isRejected) {
|
|
200
|
+
const message =
|
|
201
|
+
userResult.error?.message ||
|
|
202
|
+
userProfileResult.error?.message;
|
|
203
|
+
|
|
204
|
+
return <div>
|
|
205
|
+
Error: {message}
|
|
206
|
+
</div>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!userResult.isReady || !userProfileResult.isReady) {
|
|
210
|
+
return <div>Loading...</div>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div>
|
|
216
|
+
<h1>{userProfileResult.value.name}</h1>
|
|
217
|
+
<p>{userProfileResult.value.email}</p>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
This can be abstracted with a `useUserProfile` hook, but ultimately you still have to deal with the _combinatorial_ complexity of handling multiple requests at the same time.
|
|
224
|
+
|
|
225
|
+
With Signalium, we can use an async reactive function to simplify.
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
import { fetchQuery } from 'fetchium';
|
|
229
|
+
import { reactive } from 'signalium';
|
|
230
|
+
import { component } from 'signalium/react';
|
|
231
|
+
import { GetCurrentUser, GetUserProfile } from './queries';
|
|
232
|
+
|
|
233
|
+
const fetchUserProfile = reactive(async () => {
|
|
234
|
+
const user = await fetchQuery(GetCurrentUser);
|
|
235
|
+
|
|
236
|
+
return fetchQuery(GetUserProfile, { user });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
export const UserProfile = component(() => {
|
|
240
|
+
const result = fetchUserProfile();
|
|
241
|
+
|
|
242
|
+
if (result.isRejected) return <div>Error: {result.error.message}</div>;
|
|
243
|
+
if (!result.isReady) return <div>Loading...</div>;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<div>
|
|
247
|
+
<h1>{result.value.name}</h1>
|
|
248
|
+
<p>{result.value.email}</p>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
In the near future, we intend to make _async components_ work as well through integration with React Suspense:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
import { fetchQuery } from 'fetchium';
|
|
258
|
+
import { component } from 'signalium/react';
|
|
259
|
+
import { GetCurrentUser, GetUserProfile } from './queries';
|
|
260
|
+
|
|
261
|
+
export const UserProfile = component(async () => {
|
|
262
|
+
const user = await fetchQuery(GetCurrentUser);
|
|
263
|
+
const profile = await fetchQuery(GetUserProfile, { user });
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div>
|
|
267
|
+
<h1>{profile.name}</h1>
|
|
268
|
+
<p>{profile.email}</p>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
If you are interested in the benefits of using Signalium as a replacement for Hooks in your codebase, read the [Signalium](/reference/why-signalium) docs for more information. The remainder of this guide will show examples in both Hooks and Signalium formats based on the toggle at the top of the left-side navigation menu.
|
|
275
|
+
|
|
276
|
+
## Query Class Rules
|
|
277
|
+
|
|
278
|
+
Because Fetchium processes query classes as _definitions_ before creating real instances, there are two straightforward rules to keep in mind.
|
|
279
|
+
|
|
280
|
+
### Field values are references
|
|
281
|
+
|
|
282
|
+
When you access `this.params` in a class field, Fetchium records a _reference_ to that path - it doesn't evaluate the actual value yet. Real values are filled in later when an instance is created at fetch time. There are exactly two value uses of references in class fields:
|
|
283
|
+
|
|
284
|
+
1. As a direct reference to the exact value
|
|
285
|
+
2. As a string interpolation
|
|
286
|
+
|
|
287
|
+
You can define your own fields for reused values and logic as well, as long as they follow these rules.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
class GetUser extends RESTQuery {
|
|
291
|
+
params = {
|
|
292
|
+
id: t.number,
|
|
293
|
+
includeTags: t.optional(t.boolean),
|
|
294
|
+
apiKey: t.string,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// ✅ This is a string interpolation, which is ok
|
|
298
|
+
path = `/users/${this.params.id}`;
|
|
299
|
+
|
|
300
|
+
// ✅ This is a direct reference on an object, which is also ok
|
|
301
|
+
searchParams = {
|
|
302
|
+
includeTags: this.params.includeTags,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ✅ This is a string interpolation for a custom field, which is ok
|
|
306
|
+
apiKeyHeader = `Bearer: ${this.params.apiKey}`;
|
|
307
|
+
|
|
308
|
+
// ✅ Custom fields also have to follow these rules when referenced
|
|
309
|
+
// in other fields. This is ok, because we are just referencing the
|
|
310
|
+
// apiKeyHeader field and not doing any conditional logic with it.
|
|
311
|
+
headers = {
|
|
312
|
+
'X-API-KEY': this.apiKeyHeader,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
result = {
|
|
316
|
+
name: t.string,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
This means you can't use _logic_ in field assignments, because the reference will always be truthy. Instead, you have to use methods, which run with the _resolved_ fields:
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
class GetUser extends RESTQuery {
|
|
325
|
+
params = {
|
|
326
|
+
id: t.optional(t.number),
|
|
327
|
+
slug: t.optional(t.string),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// 🛑 Don't do this - this.params.id is a reference object,
|
|
331
|
+
// which is always truthy, so this will always pick the first branch
|
|
332
|
+
path = this.params.id ? `/users/${this.params.id}` : `/users/by-slug`;
|
|
333
|
+
|
|
334
|
+
// ✅ This is ok, getPath is called with the real values resolved
|
|
335
|
+
getPath() {
|
|
336
|
+
return this.params.id
|
|
337
|
+
? `/users/${this.params.id}`
|
|
338
|
+
: `/users/by-slug/${this.params.slug}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
result = {
|
|
342
|
+
name: t.string,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
By convention, every field provided by `RESTQuery` and other query implementations has a corresponding `get*` method. So for `path` there is `getPath`, for `headers` there is `getHeaders`, etc.
|
|
348
|
+
|
|
349
|
+
{% callout title="API design by TypeScript limitations" type="note" %}
|
|
350
|
+
The original API design for this feature allowed getters in place of fields, so `get path() {}` would work as well. The issue was that TypeScript does not allow this specific combination on abstract classes at the moment. See [this issue](https://github.com/microsoft/TypeScript/issues/40635) for more information.
|
|
351
|
+
{% /callout %}
|
|
352
|
+
|
|
353
|
+
### Avoid arrow functions for dynamic logic
|
|
354
|
+
|
|
355
|
+
Methods can resolve the actual values because JavaScript allows us to _bind_ them to the current context. Arrow functions, unfortunately, do not allow rebinding. As such, any arrow function defined within a class body will capture the `this` of the class definition itself, which will return references instead of direct values.
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
class Example extends RESTQuery {
|
|
359
|
+
params = {
|
|
360
|
+
id: t.number,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
path = `/items/${this.params.id}`;
|
|
364
|
+
|
|
365
|
+
result = {
|
|
366
|
+
name: t.string,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// ✅ regular method, called against the instance
|
|
370
|
+
getSearchParams() {
|
|
371
|
+
return { expanded: true };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 🛑 arrow function, captures the wrong `this`
|
|
375
|
+
getSearchParams = () => {
|
|
376
|
+
return { expanded: true };
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
All of the APIs for Fetchium have been defined with this in mind, including more advanced and nesting configurations. When in doubt, and when you need dynamic logic, simply switch to the `get*` method at the top level.
|
|
382
|
+
|
|
383
|
+
For instance, let's say we wanted to add an optional polling refresh to a query. The `subscribe` option on config normally allows us to add a subscription configuration (of which polling is one option, more on that in the [REST Queries reference](/reference/rest-queries)). We can pass in an exact polling time by reference like so:
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
class GetUser extends RESTQuery {
|
|
387
|
+
params = {
|
|
388
|
+
id: t.number,
|
|
389
|
+
pollInterval: t.optional(t.number),
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
path = `/items/${this.params.id}`;
|
|
393
|
+
|
|
394
|
+
result = {
|
|
395
|
+
name: t.string,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
config = {
|
|
399
|
+
subscribe: poll({ interval: this.params.pollInterval }),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
But let's say we wanted to provide a _dynamic_ polling interval based on the query response. We can't use conditional logic in the _field_ version of `config`, but we can in the _method_ version:
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
class GetUser extends RESTQuery {
|
|
408
|
+
params = {
|
|
409
|
+
id: t.number,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
path = `/items/${this.params.id}`;
|
|
413
|
+
|
|
414
|
+
result = {
|
|
415
|
+
name: t.string,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
getConfig() {
|
|
419
|
+
const lastResponseOk = this.response?.ok ?? true;
|
|
420
|
+
|
|
421
|
+
// If the last response was ok, poll quickly. Else, poll
|
|
422
|
+
// slowly - the service might be having trouble.
|
|
423
|
+
const interval = lastResponseOk ? 1_000 : 10_000;
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
subscribe: poll({ interval }),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Field reference
|
|
433
|
+
|
|
434
|
+
Here is a quick reference of the fields that are available for configuration on `RESTQuery`:
|
|
435
|
+
|
|
436
|
+
| Field | Type | Default | Description |
|
|
437
|
+
| ---------------- | ------------------------- | ------- | --------------------------------------------------------------------------------------------- |
|
|
438
|
+
| `method` | `string` | `'GET'` | HTTP method |
|
|
439
|
+
| `path` | `string` | --- | URL path with template literal param interpolation |
|
|
440
|
+
| `searchParams` | `Record<string, unknown>` | --- | Query string parameters |
|
|
441
|
+
| `body` | `Record<string, unknown>` | --- | JSON request body (auto-sets Content-Type header) |
|
|
442
|
+
| `headers` | `HeadersInit` | --- | Additional request headers |
|
|
443
|
+
| `requestOptions` | `QueryRequestOptions` | --- | Fetch options like `credentials`, `mode`, `cache`, `baseUrl` |
|
|
444
|
+
| `config` | `QueryConfigOptions` | --- | Various options for advanced query configuration, such as `subscribe`, `staleTime`, and more. |
|
|
445
|
+
|
|
446
|
+
Each of these has a corresponding `get*()` method (`getPath()`, `getSearchParams()`, `getBody()`, etc.) for when you need dynamic logic.
|
|
447
|
+
|
|
448
|
+
For a more in depth guide to query configuration, see the [REST Queries reference](/reference/rest-queries) page.
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Custom Queries
|
|
453
|
+
|
|
454
|
+
`RESTQuery` is an adapter for JSON REST APIs. But queries as a concept are protocol-agnostic. When your use case doesn't fit REST --- GraphQL, gRPC, WebSockets, local databases, or any other data source --- you build a **`QueryController`** that handles the transport, and a **`Query`** subclass that stays purely declarative.
|
|
455
|
+
|
|
456
|
+
The split follows the same logic as the rest of Fetchium: the _definition_ (params, result, identity) lives on the `Query` class; the _transport_ (how to actually fetch data) lives on the controller.
|
|
457
|
+
|
|
458
|
+
### Defining a controller
|
|
459
|
+
|
|
460
|
+
A `QueryController` handles sending requests on behalf of queries that declare it. Extend `QueryController` and implement `send(ctx, signal)`:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
import { QueryController } from 'fetchium';
|
|
464
|
+
import type { Query } from 'fetchium';
|
|
465
|
+
|
|
466
|
+
class DBQueryController extends QueryController {
|
|
467
|
+
async send(ctx: Query, signal: AbortSignal): Promise<unknown> {
|
|
468
|
+
const q = ctx as DBQuery;
|
|
469
|
+
const db = await openDatabase();
|
|
470
|
+
return db.get(q.collection, q.id);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Inside `send()`:
|
|
476
|
+
|
|
477
|
+
- **`ctx`** --- the query execution context, cast to your query type; all fields are resolved to their real values (not references)
|
|
478
|
+
- **`signal`** --- an `AbortSignal` for cancellation, passed automatically by the query lifecycle
|
|
479
|
+
- **`this.queryClient`** --- the registered `QueryClient`; call `this.queryClient.getContext()` to access `log` and any other context properties you passed at setup
|
|
480
|
+
|
|
481
|
+
Register the controller when creating the `QueryClient`:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
new QueryClient({
|
|
485
|
+
store,
|
|
486
|
+
controllers: [new DBQueryController()],
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
### Defining the query class
|
|
491
|
+
|
|
492
|
+
The query class is purely declarative. It declares `static controller` to point at the controller, defines `params`, `result`, and `getIdentityKey()`, and can include any additional fields your controller reads:
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import { Query, t } from 'fetchium';
|
|
496
|
+
|
|
497
|
+
abstract class DBQuery extends Query {
|
|
498
|
+
static override controller = DBQueryController;
|
|
499
|
+
|
|
500
|
+
abstract collection: string;
|
|
501
|
+
abstract id: unknown;
|
|
502
|
+
|
|
503
|
+
getIdentityKey() {
|
|
504
|
+
return `db:${this.collection}:${String(this.id)}`;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
class GetUser extends DBQuery {
|
|
509
|
+
params = { id: t.number };
|
|
510
|
+
|
|
511
|
+
collection = 'users';
|
|
512
|
+
id = this.params.id;
|
|
513
|
+
|
|
514
|
+
result = { name: t.string, email: t.string };
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Building a protocol adapter
|
|
519
|
+
|
|
520
|
+
Here is a more complete example --- a GraphQL adapter:
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
import { QueryController, Query, t } from 'fetchium';
|
|
524
|
+
|
|
525
|
+
// Controller: owns the transport
|
|
526
|
+
class GraphQLController extends QueryController {
|
|
527
|
+
async send(ctx: Query, signal: AbortSignal): Promise<unknown> {
|
|
528
|
+
const q = ctx as GraphQLQuery;
|
|
529
|
+
const { log } = this.queryClient!.getContext();
|
|
530
|
+
|
|
531
|
+
const response = await fetch('/graphql', {
|
|
532
|
+
method: 'POST',
|
|
533
|
+
headers: { 'Content-Type': 'application/json' },
|
|
534
|
+
body: JSON.stringify({
|
|
535
|
+
query: q.query,
|
|
536
|
+
variables: q.variables,
|
|
537
|
+
}),
|
|
538
|
+
signal,
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
const json = await response.json();
|
|
542
|
+
|
|
543
|
+
if (json.errors?.length) {
|
|
544
|
+
log?.error?.('GraphQL error', json.errors[0]);
|
|
545
|
+
throw new Error(json.errors[0].message);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return json.data;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Base query class: purely declarative
|
|
553
|
+
abstract class GraphQLQuery extends Query {
|
|
554
|
+
static override controller = GraphQLController;
|
|
555
|
+
|
|
556
|
+
abstract query: string;
|
|
557
|
+
abstract variables?: Record<string, unknown>;
|
|
558
|
+
|
|
559
|
+
getIdentityKey() {
|
|
560
|
+
return `graphql:${this.query}:${JSON.stringify(this.variables ?? {})}`;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Individual queries extend _your_ base class, not `RESTQuery`:
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
class GetUser extends GraphQLQuery {
|
|
569
|
+
params = { id: t.number };
|
|
570
|
+
|
|
571
|
+
query = `query GetUser($id: Int!) { user(id: $id) { name email } }`;
|
|
572
|
+
variables = { id: this.params.id };
|
|
573
|
+
|
|
574
|
+
result = { user: t.object({ name: t.string, email: t.string }) };
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Custom queries participate in all the same systems as `RESTQuery` --- caching, entity normalization, live data, refetching, and pagination (via `sendNext()` and `hasNext()` on the controller). The `Query` base class provides the full reactive lifecycle; your controller only needs to implement the transport.
|
|
579
|
+
|
|
580
|
+
{% callout title="The identity key" type="note" %}
|
|
581
|
+
`getIdentityKey()` returns a value that uniquely identifies this query's _definition_. Two query instances with the same identity key and the same params share the same cache entry and are deduplicated. For `RESTQuery`, the default is `${method}:${path}`. For custom adapters, choose a key that captures all the inputs that make a query unique.
|
|
582
|
+
{% /callout %}
|
|
583
|
+
|
|
584
|
+
---
|
|
585
|
+
|
|
586
|
+
Now that you understand the basics of defining and using Queries, let's dive into _query types_ and _parsing_.
|
|
587
|
+
|
|
588
|
+
## Next Steps
|
|
589
|
+
|
|
590
|
+
{% quick-links %}
|
|
591
|
+
|
|
592
|
+
{% quick-link title="Types" icon="presets" href="/core/types" description="The full type system for params, results, and entity fields" /%}
|
|
593
|
+
|
|
594
|
+
{% quick-link title="Entities" icon="plugins" href="/core/entities" description="Normalized entity caching and identity-stable proxies" /%}
|
|
595
|
+
|
|
596
|
+
{% quick-link title="Mutations" icon="theming" href="/data/mutations" description="Create, update, and delete data with optimistic updates" /%}
|
|
597
|
+
|
|
598
|
+
{% quick-link title="REST Queries Reference" icon="installation" href="/reference/rest-queries" description="Override methods, identity keys, and dynamic configuration" /%}
|
|
599
|
+
|
|
600
|
+
{% /quick-links %}
|