@mmstack/resource 21.1.0 → 21.1.2
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/README.md +508 -73
- package/fesm2022/mmstack-resource.mjs +151 -67
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/package.json +5 -3
- package/types/mmstack-resource.d.ts +81 -11
package/README.md
CHANGED
|
@@ -3,118 +3,553 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@mmstack/resource)
|
|
4
4
|
[](https://github.com/mihajm/mmstack/blob/master/packages/resource/LICENSE)
|
|
5
5
|
|
|
6
|
-
`@mmstack/resource` is
|
|
6
|
+
`@mmstack/resource` is a signal-native data-fetching layer for Angular built on top of `httpResource`. It adds caching, retries, refresh intervals, circuit breakers, request deduplication, optimistic mutations, and stale-while-revalidate semantics — the surface TanStack Query gives React, but expressed with Angular signals rather than RxJS/Promises.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
It's designed to be opt-in feature by feature: starting with `queryResource()` and zero options gives you exactly `httpResource`. Every additional knob (cache, retry, refresh, circuit breaker) is independent and composable.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
- **Caching:** Built-in caching with configurable TTL (Time To Live) and stale-while-revalidate behavior. Supports custom cache key generation and respects HTTP caching headers.
|
|
12
|
-
- **Retries:** Automatic retries on failure with configurable backoff strategies.
|
|
13
|
-
- **Refresh Intervals:** Automatically refetch data at specified intervals.
|
|
14
|
-
- **Circuit Breaker:** Protects your application from cascading failures by temporarily disabling requests to failing endpoints.
|
|
15
|
-
- **Request Deduplication:** Avoids making multiple identical requests concurrently.
|
|
16
|
-
- **Mutations:** Provides a dedicated `mutationResource` for handling data modifications, with callbacks for `onMutate`, `onError`, `onSuccess`, and `onSettled`.
|
|
17
|
-
- **Prefetching:** Allows you to prefetch data into the cache, improving perceived performance.
|
|
18
|
-
- **Extensible:** Designed to be modular and extensible. You can easily add your own custom features or integrate with other libraries.
|
|
19
|
-
- **TypeScript Support:** A strong focus on typesafety
|
|
10
|
+
## Contents
|
|
20
11
|
|
|
21
|
-
|
|
12
|
+
- [Install](#install)
|
|
13
|
+
- [Quick start](#quick-start)
|
|
14
|
+
- [Core concepts](#core-concepts)
|
|
15
|
+
- [Resources](#resources)
|
|
16
|
+
- [Cache + cache keys](#cache--cache-keys)
|
|
17
|
+
- [Stale-while-revalidate](#stale-while-revalidate)
|
|
18
|
+
- [Interceptors](#interceptors)
|
|
19
|
+
- [`queryResource`](#queryresource)
|
|
20
|
+
- [`mutationResource`](#mutationresource)
|
|
21
|
+
- [`manualQueryResource`](#manualqueryresource)
|
|
22
|
+
- [Caching](#caching)
|
|
23
|
+
- [Circuit breakers](#circuit-breakers)
|
|
24
|
+
- [Composition (retry / refresh / keepPrevious)](#composition-retry--refresh--keepprevious)
|
|
25
|
+
- [Recipes](#recipes)
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
## Install
|
|
24
28
|
|
|
25
29
|
```bash
|
|
26
30
|
npm install @mmstack/resource
|
|
27
31
|
```
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
Two-step setup: provide the cache + interceptors in your app config, then create resources in your services or components.
|
|
30
36
|
|
|
31
37
|
```typescript
|
|
32
38
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
33
39
|
import { ApplicationConfig } from '@angular/core';
|
|
34
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
createCacheInterceptor,
|
|
42
|
+
createDedupeRequestsInterceptor,
|
|
43
|
+
provideQueryCache,
|
|
44
|
+
} from '@mmstack/resource';
|
|
35
45
|
|
|
36
46
|
export const appConfig: ApplicationConfig = {
|
|
37
47
|
providers: [
|
|
38
|
-
// ..other providers
|
|
39
48
|
provideQueryCache(),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// }),
|
|
47
|
-
|
|
48
|
-
provideHttpClient(withInterceptors([createCacheInterceptor(), createDedupeRequestsInterceptor()])),
|
|
49
|
+
provideHttpClient(
|
|
50
|
+
withInterceptors([
|
|
51
|
+
createCacheInterceptor(),
|
|
52
|
+
createDedupeRequestsInterceptor(),
|
|
53
|
+
]),
|
|
54
|
+
),
|
|
49
55
|
],
|
|
50
56
|
};
|
|
51
57
|
```
|
|
52
58
|
|
|
53
|
-
3. Use it :)
|
|
54
|
-
|
|
55
59
|
```typescript
|
|
56
60
|
import { Injectable, isDevMode, untracked } from '@angular/core';
|
|
57
|
-
import {
|
|
61
|
+
import {
|
|
62
|
+
createCircuitBreaker,
|
|
63
|
+
mutationResource,
|
|
64
|
+
queryResource,
|
|
65
|
+
} from '@mmstack/resource';
|
|
58
66
|
|
|
59
|
-
type Post = {
|
|
60
|
-
userId: number;
|
|
61
|
-
id: number;
|
|
62
|
-
title: string;
|
|
63
|
-
body: string;
|
|
64
|
-
};
|
|
67
|
+
type Post = { userId: number; id: number; title: string; body: string };
|
|
65
68
|
|
|
66
|
-
@Injectable({
|
|
67
|
-
providedIn: 'root',
|
|
68
|
-
})
|
|
69
|
+
@Injectable({ providedIn: 'root' })
|
|
69
70
|
export class PostsService {
|
|
70
71
|
private readonly endpoint = 'https://jsonplaceholder.typicode.com/posts';
|
|
71
72
|
private readonly cb = createCircuitBreaker();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
onError: (err) => {
|
|
82
|
-
if (!isDevMode()) return;
|
|
83
|
-
console.error(err);
|
|
84
|
-
}, // log errors in dev mode
|
|
85
|
-
defaultValue: [],
|
|
86
|
-
},
|
|
87
|
-
);
|
|
73
|
+
|
|
74
|
+
readonly posts = queryResource<Post[]>(() => ({ url: this.endpoint }), {
|
|
75
|
+
keepPrevious: true,
|
|
76
|
+
refresh: 5 * 60_000,
|
|
77
|
+
circuitBreaker: this.cb,
|
|
78
|
+
retry: 3,
|
|
79
|
+
defaultValue: [],
|
|
80
|
+
onError: (err) => isDevMode() && console.error(err),
|
|
81
|
+
});
|
|
88
82
|
|
|
89
83
|
private readonly createPostResource = mutationResource(
|
|
90
|
-
(post: Post) => ({
|
|
91
|
-
url: this.endpoint,
|
|
92
|
-
method: 'POST',
|
|
93
|
-
body: post,
|
|
94
|
-
}),
|
|
84
|
+
(post: Post) => ({ url: this.endpoint, method: 'POST', body: post }),
|
|
95
85
|
{
|
|
96
|
-
circuitBreaker: this.cb,
|
|
97
|
-
onMutate: (post
|
|
86
|
+
circuitBreaker: this.cb,
|
|
87
|
+
onMutate: (post) => {
|
|
98
88
|
const prev = untracked(this.posts.value);
|
|
99
|
-
this.posts.set([...prev, post]);
|
|
100
|
-
return prev;
|
|
101
|
-
},
|
|
102
|
-
onError: (err, prev) => {
|
|
103
|
-
if (isDevMode()) console.error(err);
|
|
104
|
-
this.posts.set(prev); // rollback on error
|
|
105
|
-
},
|
|
106
|
-
onSuccess: (next) => {
|
|
107
|
-
this.posts.update((posts) => posts.map((p) => (p.id === next.id ? next : p))); // replace with value from server
|
|
89
|
+
this.posts.set([...prev, post]);
|
|
90
|
+
return prev; // ctx for rollback
|
|
108
91
|
},
|
|
92
|
+
onError: (_err, prev) => this.posts.set(prev),
|
|
93
|
+
onSuccess: (saved) =>
|
|
94
|
+
this.posts.update((posts) =>
|
|
95
|
+
posts.map((p) => (p.id === saved.id ? saved : p)),
|
|
96
|
+
),
|
|
109
97
|
},
|
|
110
98
|
);
|
|
111
99
|
|
|
112
100
|
createPost(post: Post) {
|
|
113
|
-
this.createPostResource.mutate(post);
|
|
101
|
+
this.createPostResource.mutate(post);
|
|
114
102
|
}
|
|
115
103
|
}
|
|
116
104
|
```
|
|
117
105
|
|
|
118
|
-
|
|
106
|
+
That's enough for caching, deduping, retries, circuit-breaker protection, and optimistic updates. The rest of the README explains each piece.
|
|
107
|
+
|
|
108
|
+
## Core concepts
|
|
109
|
+
|
|
110
|
+
### Resources
|
|
111
|
+
|
|
112
|
+
The library exposes three resource flavors, all built on `httpResource`:
|
|
113
|
+
|
|
114
|
+
| Function | Use for | Triggers on |
|
|
115
|
+
| ----------------------- | ----------------------------------------------- | -------------------------- |
|
|
116
|
+
| `queryResource()` | Reads. Cached, refreshable, retryable. | Reactive request fn change |
|
|
117
|
+
| `mutationResource()` | Writes. Lifecycle hooks for optimistic updates. | Explicit `.mutate(value)` |
|
|
118
|
+
| `manualQueryResource()` | Reads that should only fire on demand. | Explicit `.trigger()` |
|
|
119
|
+
|
|
120
|
+
All three return a signal-typed ref — `value()`, `status()`, `error()`, `headers()`, `statusCode()`, plus per-flavor extras (`prefetch`, `mutate`, `trigger`).
|
|
121
|
+
|
|
122
|
+
### Cache + cache keys
|
|
123
|
+
|
|
124
|
+
When the cache interceptor is registered (`createCacheInterceptor()`) and a query resource opts in via `cache`, responses are stored in the shared `Cache` keyed by a string derived from the request.
|
|
125
|
+
|
|
126
|
+
**Default key**: `${method} ${urlWithParams(request)}` — produced by `urlWithParams()` (`util/url-with-params.ts:24`). It includes method, URL path, and sorted query params. **It does not include headers, body, or `HttpContext`.**
|
|
127
|
+
|
|
128
|
+
If two requests should _not_ share a cache entry but the default key would collide (e.g. different `Authorization` headers, request body in a GET-equivalent POST), pass a custom hash:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
queryResource<Post>(() => ({ url, headers }), {
|
|
132
|
+
cache: {
|
|
133
|
+
hash: (req) =>
|
|
134
|
+
`${req.method}:${req.urlWithParams}:${req.headers.get('Authorization') ?? ''}`,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
> **Note:** A custom `parse()` does not affect the cache key. Two requests that share a URL but parse differently will share a cache entry containing the _raw_ server response; the parser is applied to the cached value on read.
|
|
140
|
+
|
|
141
|
+
### Stale-while-revalidate
|
|
142
|
+
|
|
143
|
+
Cache entries have two durations:
|
|
144
|
+
|
|
145
|
+
- **`staleTime`** — how long the entry is fresh. Reads within this window return cached data and _do not refetch_.
|
|
146
|
+
- **`ttl`** — how long the entry lives in the cache at all. After `ttl`, the entry is evicted.
|
|
147
|
+
|
|
148
|
+
Between `staleTime` and `ttl`, the cached value is **stale-but-valid**: the resource returns it immediately, then triggers a background fetch to revalidate. Consumers see the cached value first, then the fresh value when it lands.
|
|
149
|
+
|
|
150
|
+
HTTP `Cache-Control` and `ETag`/`Last-Modified` headers are respected by default. A response with `s-maxage=60` will be considered fresh for 60s, `stale-while-revalidate=300` extends the stale window by 5 min, and 304 responses are honored. To opt out per-resource, pass `cache: { ignoreCacheControl: true }`.
|
|
151
|
+
|
|
152
|
+
### Interceptors
|
|
153
|
+
|
|
154
|
+
Two interceptors ship with the library, both registered via `withInterceptors([...])`:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
withInterceptors([
|
|
158
|
+
createCacheInterceptor(), // 1. cache lookup + store
|
|
159
|
+
createDedupeRequestsInterceptor(), // 2. dedupe in-flight requests
|
|
160
|
+
]);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Order matters but only weakly: the cache interceptor short-circuits cached responses before they reach the network, the dedupe interceptor coalesces identical in-flight requests so duplicate consumers share one network round-trip. The order above is the safe default.
|
|
164
|
+
|
|
165
|
+
Both default to intercepting only GET. Pass an array to extend: `createCacheInterceptor(['GET', 'HEAD'])`.
|
|
166
|
+
|
|
167
|
+
To opt a single request out of dedup, attach `noDedupe()` to its context:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
queryResource(() => ({
|
|
171
|
+
url: '/api/data',
|
|
172
|
+
context: noDedupe(),
|
|
173
|
+
}));
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## `queryResource`
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
queryResource<TResult, TRaw = TResult>(
|
|
180
|
+
request: () => HttpResourceRequest | string | undefined,
|
|
181
|
+
options?: QueryResourceOptions<TResult, TRaw>,
|
|
182
|
+
): QueryResourceRef<TResult>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`request` is a reactive function. Whenever it returns a new value, a new request is made; returning `undefined` disables the resource until the function returns something again.
|
|
186
|
+
|
|
187
|
+
### Options
|
|
188
|
+
|
|
189
|
+
| Option | Type | Default | What it does |
|
|
190
|
+
| ---------------------- | ------------------------------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
|
191
|
+
| `defaultValue` | `TResult` | – | Initial value before the first request resolves. When set, `value()` is `TResult`, not `TResult \| undefined`. |
|
|
192
|
+
| `keepPrevious` | `boolean` | `false` | Hold the previous `value`, `status`, and `headers` while a refresh is in flight. Powered by `linkedSignal`. |
|
|
193
|
+
| `refresh` | `number` (ms) | – | Auto-refetch interval. |
|
|
194
|
+
| `retry` | `number \| { max, backoff }` | `0` | On failure, retry N times with exponential backoff (default 1000ms × 2^n). |
|
|
195
|
+
| `onError` | `(err, retryCount, isFinal) => void` | – | Called on **every** failed attempt. `retryCount` is the number of retries already done (`0` on the first failure). `isFinal` is `true` when no further retry will be scheduled — branch on it to separate per-attempt instrumentation from "user-needs-to-know" side effects. |
|
|
196
|
+
| `circuitBreaker` | `true \| CircuitBreaker \| { threshold?, timeout?, … }` | off | See [circuit breakers](#circuit-breakers). |
|
|
197
|
+
| `cache` | `ResourceCacheOptions` | off | Enables caching for this resource. See [caching](#caching). |
|
|
198
|
+
| `triggerOnSameRequest` | `boolean` | `false` | Re-run even if the request object equals the previous one. Use sparingly. |
|
|
199
|
+
| `equal` | `ValueEqualityFn<TResult>` | `Object.is` | Custom equality for the result value (forwarded to `httpResource`). |
|
|
200
|
+
| `injector` | `Injector` | `inject(Injector)` | Use this injector for cache/circuit-breaker resolution. Required if calling outside an injection context. |
|
|
201
|
+
| `parse` | `(raw: TRaw) => TResult` | identity | Transform the raw HTTP response. Does not affect cache keys. |
|
|
202
|
+
|
|
203
|
+
### Return shape (`QueryResourceRef<T>`)
|
|
204
|
+
|
|
205
|
+
| Member | Type | Notes |
|
|
206
|
+
| ------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
207
|
+
| `value` | `WritableSignal<T>` | The current value. Writable so optimistic mutations can update it. |
|
|
208
|
+
| `status` | `Signal<ResourceStatus>` | `'idle' \| 'loading' \| 'error' \| 'reloading' \| 'resolved' \| …` |
|
|
209
|
+
| `error` | `Signal<unknown>` | – |
|
|
210
|
+
| `headers` | `WritableSignal<HttpHeaders \| undefined>` | Held when `keepPrevious: true`. |
|
|
211
|
+
| `statusCode` | `WritableSignal<number \| undefined>` | – |
|
|
212
|
+
| `isLoading` | `Signal<boolean>` | – |
|
|
213
|
+
| `hasValue` | `Signal<boolean>` | – |
|
|
214
|
+
| `disabled` | `Signal<boolean>` | `true` when network is offline, circuit breaker is open, or `request()` returned `undefined`. |
|
|
215
|
+
| `disabledReason` | `Signal<'offline' \| 'circuit-open' \| 'no-request' \| null>` | Why the resource is disabled. `null` when enabled. Branch your UI on this rather than parsing combined state. |
|
|
216
|
+
| `reload` | `() => void` | Force a refetch (ignores `staleTime` for the next request). |
|
|
217
|
+
| `prefetch` | `(req?) => Promise<void>` | Warm the cache without subscribing. Silently skips on slow connections (`saveData` / 2g). |
|
|
218
|
+
| `destroy` | `() => void` | – |
|
|
219
|
+
|
|
220
|
+
## `mutationResource`
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
mutationResource<TResult, TRaw, TMutation, TCTX, TICTX>(
|
|
224
|
+
request: (params: TMutation) => HttpResourceRequest | undefined,
|
|
225
|
+
options?: MutationResourceOptions<...>,
|
|
226
|
+
): MutationResourceRef<TResult, TMutation, TICTX>
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Unlike `queryResource`, a mutation only fires when you call `.mutate(value)`. It cannot be cached (and intentionally rejects `cache`, `keepPrevious`, and `refresh` options).
|
|
230
|
+
|
|
231
|
+
### Lifecycle hooks
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
mutationResource(
|
|
235
|
+
(post: Post) => ({ url: '/posts', method: 'POST', body: post }),
|
|
236
|
+
{
|
|
237
|
+
onMutate: (post, initialCtx) => {
|
|
238
|
+
// 1. fires synchronously before the request
|
|
239
|
+
// return a ctx value that's passed to the other hooks
|
|
240
|
+
const prev = untracked(this.posts.value);
|
|
241
|
+
this.posts.set([...prev, post]);
|
|
242
|
+
return prev;
|
|
243
|
+
},
|
|
244
|
+
onError: (err, ctx /* = prev */) => {
|
|
245
|
+
// 2a. fires on failure — use ctx to roll back
|
|
246
|
+
this.posts.set(ctx);
|
|
247
|
+
},
|
|
248
|
+
onSuccess: (saved, ctx) => {
|
|
249
|
+
// 2b. fires on success — replace the optimistic entry with server truth
|
|
250
|
+
this.posts.update((posts) =>
|
|
251
|
+
posts.map((p) => (p.id === saved.id ? saved : p)),
|
|
252
|
+
);
|
|
253
|
+
},
|
|
254
|
+
onSettled: (ctx) => {
|
|
255
|
+
// 3. fires after either branch — cleanup, refetch, etc.
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The `TCTX` returned from `onMutate` flows into `onError` / `onSuccess` / `onSettled`. The optional `initialCtx` second arg to `.mutate(value, initialCtx)` flows into `onMutate` as its second argument.
|
|
262
|
+
|
|
263
|
+
### Queuing
|
|
264
|
+
|
|
265
|
+
By default, calling `.mutate()` while another mutation is in flight starts immediately — concurrent mutations run in parallel. With `queue: true`, mutations are serialized:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
mutationResource(request, { queue: true });
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Queued mutations sit in a signal-backed queue and execute one at a time. The queue **persists across resource-disabled states** — if the circuit breaker opens or the network drops, queued mutations stay pending and run when the resource recovers. This is intentional for resilience (think "POST goes out when we're back online"), but it does mean a queued mutation can fire long after the user triggered it. Don't enable `queue` if that's surprising in your UX.
|
|
272
|
+
|
|
273
|
+
### Return shape (`MutationResourceRef<T, TMutation>`)
|
|
274
|
+
|
|
275
|
+
| Member | Type | Notes |
|
|
276
|
+
| --------------------------------------------- | --------------------------- | ------------------------------------------------------ |
|
|
277
|
+
| `mutate` | `(value, ctx?) => void` | Trigger the mutation. |
|
|
278
|
+
| `current` | `Signal<TMutation \| null>` | The value currently being mutated (or `null` if idle). |
|
|
279
|
+
| `status` / `error` / `isLoading` / `disabled` | as in `QueryResourceRef` | – |
|
|
280
|
+
|
|
281
|
+
(Mutations deliberately don't expose `value`, `hasValue`, `set`, `update`, or `prefetch` — those don't make sense for one-off writes.)
|
|
282
|
+
|
|
283
|
+
## `manualQueryResource`
|
|
284
|
+
|
|
285
|
+
Same shape as `queryResource`, but only fires when you call `.trigger()`. Useful for searches, "load more" buttons, and any read that shouldn't fire on construction.
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
const search = manualQueryResource<SearchResult[]>(() => ({
|
|
289
|
+
url: '/api/search',
|
|
290
|
+
params: { q: this.query() },
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
// in a handler:
|
|
294
|
+
onSubmit() {
|
|
295
|
+
search.trigger();
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`.trigger()` re-evaluates the `request()` function and fires. Everything else (`value`, `status`, `error`, retry, cache, etc.) works identically.
|
|
300
|
+
|
|
301
|
+
## Caching
|
|
302
|
+
|
|
303
|
+
### `provideQueryCache(options?)`
|
|
304
|
+
|
|
305
|
+
Registers the shared `Cache` in the root injector.
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
provideQueryCache({
|
|
309
|
+
staleTime: 60_000, // default freshness, default: 1 hour
|
|
310
|
+
ttl: 5 * 60_000, // default eviction, default: same as staleTime
|
|
311
|
+
cacheSize: 100, // max entries before LRU eviction
|
|
312
|
+
persist: true, // mirror to IndexedDB
|
|
313
|
+
version: 1, // bumping invalidates persisted entries
|
|
314
|
+
syncTabs: true, // sync invalidations across tabs via BroadcastChannel
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### `ResourceCacheOptions` (per-resource `cache: { … }`)
|
|
319
|
+
|
|
320
|
+
| Field | Default | Notes |
|
|
321
|
+
| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
|
|
322
|
+
| `staleTime` | from `provideQueryCache` | Per-resource override. |
|
|
323
|
+
| `ttl` | from `provideQueryCache` | Per-resource override. |
|
|
324
|
+
| `hash` | `urlWithParams` | Custom cache key function. See [cache + cache keys](#cache--cache-keys). |
|
|
325
|
+
| `persist` | `false` | Mirror this resource's responses to IndexedDB (only effective if the cache itself was created with `persist: true`). |
|
|
326
|
+
| `ignoreCacheControl` | `false` | Ignore HTTP `Cache-Control` directives and use only `staleTime`/`ttl`. |
|
|
327
|
+
|
|
328
|
+
Pass `cache: true` as a shorthand for "use the cache with defaults," or `cache: { … }` for fine-tuning.
|
|
329
|
+
|
|
330
|
+
### IndexedDB persistence
|
|
331
|
+
|
|
332
|
+
When `provideQueryCache({ persist: true })` is set, the cache mirrors entries to IndexedDB on write and rehydrates on app start. Entries that are still fresh come back as if the page never reloaded.
|
|
333
|
+
|
|
334
|
+
Bumping `version` invalidates the entire persisted store — useful when your response shapes change. The cache stores `Cache-Control` metadata alongside the value, so persisted entries respect the same freshness rules as in-memory ones.
|
|
335
|
+
|
|
336
|
+
You can also opt-in to persistance on a per-resource basis via the cache settings.
|
|
337
|
+
|
|
338
|
+
`HttpHeaders` and `HttpParams` are serialized to plain objects for storage. Non-serializable values in headers (functions, references) are dropped silently — if you depend on something custom in headers, use a custom `hash` instead of relying on persistence to round-trip it.
|
|
339
|
+
|
|
340
|
+
### Cross-tab sync
|
|
341
|
+
|
|
342
|
+
With `syncTabs: true`, cache invalidations and updates broadcast via `BroadcastChannel`. Tab A writes a fresh response, Tab B sees it — no extra network call. SSR-safe (the channel is created only in the browser).
|
|
343
|
+
|
|
344
|
+
### Manual control: `injectQueryCache()`
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
const cache = injectQueryCache<MyResponse>();
|
|
348
|
+
cache.invalidate('GET /api/posts'); // drop one entry by key
|
|
349
|
+
cache.invalidateAll(); // drop everything
|
|
350
|
+
cache.store(key, value, staleTime, ttl); // imperative write
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Useful for "log out → invalidate all user-scoped queries" or "mutation succeeds → invalidate a specific list."
|
|
354
|
+
|
|
355
|
+
## Circuit breakers
|
|
356
|
+
|
|
357
|
+
A circuit breaker pauses requests to an endpoint after a configurable number of failures and tries again after a timeout. Three states:
|
|
358
|
+
|
|
359
|
+
- **`CLOSED`** — normal operation, requests go through.
|
|
360
|
+
- **`OPEN`** — failure threshold hit; new requests are short-circuited (the resource's `disabled()` returns `true`).
|
|
361
|
+
- **`HALF_OPEN`** — after the timeout, one probe request is allowed. Success → back to `CLOSED`, failure → back to `OPEN`.
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
const cb = createCircuitBreaker({
|
|
365
|
+
threshold: 5, // open after 5 failures
|
|
366
|
+
timeout: 30_000, // probe after 30s
|
|
367
|
+
shouldFail: (err) => true, // which errors count as failures
|
|
368
|
+
shouldFailForever: (err) => false, // which errors permanently break the circuit (e.g. 401)
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
queryResource(() => ({ url: '/api/data' }), { circuitBreaker: cb });
|
|
372
|
+
mutationResource(() => ({ url: '/api/posts', method: 'POST' }), {
|
|
373
|
+
circuitBreaker: cb,
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Sharing one `cb` across multiple resources means a flaky endpoint trips the breaker once and protects every consumer. Per-resource breakers (`circuitBreaker: true` or `circuitBreaker: { threshold: 3 }`) create independent state.
|
|
378
|
+
|
|
379
|
+
> The misspelled `treshold` field is still accepted as a deprecated alias for `threshold` (it'll be removed in a future major).
|
|
380
|
+
|
|
381
|
+
### App-wide defaults
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
provideCircuitBreakerDefaultOptions({
|
|
385
|
+
threshold: 10,
|
|
386
|
+
timeout: 60_000,
|
|
387
|
+
});
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Every `createCircuitBreaker()` call without explicit options will pick these up.
|
|
391
|
+
|
|
392
|
+
### `shouldFailForever` and `hardReset()`
|
|
393
|
+
|
|
394
|
+
For errors that won't resolve themselves (401 with an invalid token, 403 from a permission boundary), `shouldFailForever` permanently opens the breaker — no probe retries, no timeout. The resource stays `disabled` until you explicitly recover.
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
const cb = createCircuitBreaker({
|
|
398
|
+
shouldFailForever: (err) =>
|
|
399
|
+
err instanceof HttpErrorResponse && [401, 403].includes(err.status),
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
To recover (e.g. after the user re-authenticates), call `hardReset()`:
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
authService.refreshToken().subscribe(() => {
|
|
407
|
+
cb.hardReset(); // clears failure count, drops permanent-open, breaker back to CLOSED
|
|
408
|
+
});
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
`hardReset()` is also useful for testing — it gives you a "back to factory state" handle without reconstructing the breaker.
|
|
412
|
+
|
|
413
|
+
## Composition (retry / refresh / keepPrevious)
|
|
414
|
+
|
|
415
|
+
The wrappers stack in a fixed order inside `queryResource`:
|
|
416
|
+
|
|
417
|
+
```
|
|
418
|
+
request -> stableRequest (network + circuit breaker gate)
|
|
419
|
+
-> httpResource
|
|
420
|
+
-> retryOnError (retries on every failure up to `retry.max`)
|
|
421
|
+
-> refreshOnInterval (re-runs every `refresh` ms)
|
|
422
|
+
-> persistResourceValues (carries previous value/headers/status forward when `keepPrevious`)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Practical consequences:
|
|
426
|
+
|
|
427
|
+
- **`retry` and `refresh` are independent.** A retry exhaustion doesn't disable refresh; a successful refresh resets the retry counter for the next failure.
|
|
428
|
+
- **`keepPrevious` works alongside both.** While a retry or refresh is in flight, `value()` is the previous successful result, not `undefined`.
|
|
429
|
+
- **Circuit breaker beats retry.** If the breaker opens during a retry sequence, the resource is disabled — no more retries until the breaker probes and closes.
|
|
430
|
+
|
|
431
|
+
## Recipes
|
|
432
|
+
|
|
433
|
+
### Optimistic update with rollback
|
|
434
|
+
|
|
435
|
+
The Quick Start example covers this — `onMutate` returns the previous value as ctx, `onError` restores it. The key detail: read the previous value with `untracked()` so you don't create a spurious dependency.
|
|
436
|
+
|
|
437
|
+
### Manual invalidation after a mutation
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
const cache = injectQueryCache();
|
|
441
|
+
mutationResource((p: Post) => ({ url: '/posts', method: 'POST', body: p }), {
|
|
442
|
+
onSuccess: () => {
|
|
443
|
+
// Drop every paginated `GET /posts*` cache entry in one shot.
|
|
444
|
+
cache.invalidatePrefix('GET /posts');
|
|
445
|
+
// Or, for arbitrary predicates:
|
|
446
|
+
// cache.invalidateWhere((key) => key.includes('/posts'));
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
`invalidate(key)` drops a single entry, `invalidatePrefix(prefix)` drops every key starting with the prefix (most common after list-mutating writes), and `invalidateWhere(predicate)` handles anything else. Both bulk variants return the number of entries removed.
|
|
452
|
+
|
|
453
|
+
### Prefetch on hover
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
@Component({
|
|
457
|
+
template: `
|
|
458
|
+
<a
|
|
459
|
+
(mouseenter)="posts.prefetch({ url: '/posts/' + id() })"
|
|
460
|
+
[routerLink]="['/posts', id()]"
|
|
461
|
+
>
|
|
462
|
+
{{ title() }}
|
|
463
|
+
</a>
|
|
464
|
+
`,
|
|
465
|
+
})
|
|
466
|
+
export class PostLink {
|
|
467
|
+
readonly id = input.required<number>();
|
|
468
|
+
readonly title = input.required<string>();
|
|
469
|
+
readonly posts = injectPostsResource();
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`prefetch()` skips automatically on slow connections (`navigator.connection.saveData`, `effectiveType: '2g'`), so this is safe to wire up without conditional checks.
|
|
474
|
+
|
|
475
|
+
### Polling with backoff on error
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
queryResource(() => ({ url: '/api/job-status' }), {
|
|
479
|
+
refresh: 5_000,
|
|
480
|
+
retry: { max: 3, backoff: 2_000 },
|
|
481
|
+
circuitBreaker: { threshold: 5, timeout: 60_000 },
|
|
482
|
+
});
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Five-second refresh; on failure, retry three times with exponential backoff starting at 2s; if five consecutive failures stack up, the circuit breaker pauses polling for a minute.
|
|
486
|
+
|
|
487
|
+
### Branching UI on `disabledReason`
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
@Component({
|
|
491
|
+
template: `
|
|
492
|
+
@switch (posts.disabledReason()) {
|
|
493
|
+
@case ('offline') {
|
|
494
|
+
<p>You're offline. Cached posts shown below.</p>
|
|
495
|
+
}
|
|
496
|
+
@case ('circuit-open') {
|
|
497
|
+
<p>The posts service is having trouble. Retrying soon…</p>
|
|
498
|
+
}
|
|
499
|
+
@default {
|
|
500
|
+
<ul>
|
|
501
|
+
@for (p of posts.value(); track p.id) {
|
|
502
|
+
<li>{{ p.title }}</li>
|
|
503
|
+
}
|
|
504
|
+
</ul>
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
`,
|
|
508
|
+
})
|
|
509
|
+
export class PostsList {
|
|
510
|
+
readonly posts = injectPostsResource();
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### Retry-aware logging vs user-facing errors
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
queryResource(() => ({ url: '/api/data' }), {
|
|
518
|
+
retry: 3,
|
|
519
|
+
onError: (err, retryCount, isFinal) => {
|
|
520
|
+
if (!isFinal) {
|
|
521
|
+
// Per-attempt log, only useful in dev or for telemetry
|
|
522
|
+
if (isDevMode()) console.warn(`Attempt ${retryCount + 1} failed`, err);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// Final failure (retries exhausted, or retry=0) — the "user needs to know" path
|
|
526
|
+
toaster.error('Could not load data. Please try again.');
|
|
527
|
+
Sentry.captureException(err);
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Recovering a permanently-tripped circuit breaker
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
const cb = createCircuitBreaker({
|
|
536
|
+
shouldFailForever: (err) =>
|
|
537
|
+
err instanceof HttpErrorResponse && err.status === 401,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// elsewhere, after re-auth:
|
|
541
|
+
authService.onRefresh(() => cb.hardReset());
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### Reading the cache directly (e.g. in a guard)
|
|
545
|
+
|
|
546
|
+
```typescript
|
|
547
|
+
export const userGuard = () => {
|
|
548
|
+
const cache = injectQueryCache<User>();
|
|
549
|
+
const cached = cache.getUntracked('GET /me');
|
|
550
|
+
if (cached) return true;
|
|
551
|
+
return inject(Router).parseUrl('/login');
|
|
552
|
+
};
|
|
553
|
+
```
|
|
119
554
|
|
|
120
|
-
|
|
555
|
+
`getUntracked` reads without subscribing — important inside guards where reactivity could cause re-entrancy.
|