@mmstack/resource 21.1.1 → 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 CHANGED
@@ -3,118 +3,553 @@
3
3
  [![npm version](https://badge.fury.io/js/%40mmstack%2Fresource.svg)](https://www.npmjs.com/package/@mmstack/resource)
4
4
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/packages/resource/LICENSE)
5
5
 
6
- `@mmstack/resource` is an Angular library that provides powerful, signal-based primitives for managing asynchronous data fetching and mutations. It builds upon Angular's `httpResource` and offers features like caching, retries, refresh intervals, circuit breakers, and request deduplication, all while maintaining a fine-grained reactive graph. It's inspired by libraries like TanStack Query, but aims for a more Angular-idiomatic and signal-centric approach.
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
- ## Features
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
- - **Signal-Based:** Fully integrates with Angular's signal system for efficient change detection and reactivity.
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
- ## Quick Start
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
- 1. Install mmstack-resource
27
+ ## Install
24
28
 
25
29
  ```bash
26
30
  npm install @mmstack/resource
27
31
  ```
28
32
 
29
- 2. Initialize the QueryCache & interceptors (optional)
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 { createCacheInterceptor, createDedupeRequestsInterceptor, provideQueryCache } from '@mmstack/resource';
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
- // --- Example of a more advanced setup ---
42
- // provideQueryCache({
43
- // persist: true, // Enable IndexedDB persistence
44
- // version: 1, // Version for the cache schema
45
- // syncTabs: true // enable BroadcastChannel
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 { createCircuitBreaker, mutationResource, queryResource } from '@mmstack/resource';
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
- readonly posts = queryResource<Post[]>(
73
- () => ({
74
- url: this.endpoint,
75
- }),
76
- {
77
- keepPrevious: true, // keep data between requests
78
- refresh: 5 * 60 * 1000, // refresh every 5 minutes
79
- circuitBreaker: this.cb, // use shared circuit breaker use true if not sharing
80
- retry: 3, // retry 3 times on error using default backoff
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, // use shared circuit breaker use true if not sharing
97
- onMutate: (post: Post) => {
86
+ circuitBreaker: this.cb,
87
+ onMutate: (post) => {
98
88
  const prev = untracked(this.posts.value);
99
- this.posts.set([...prev, post]); // optimistically update
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); // send the request
101
+ this.createPostResource.mutate(post);
114
102
  }
115
103
  }
116
104
  ```
117
105
 
118
- ## In-depth
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
- For an in-depth explanation of the primitives & how they work check out this article: [Fun-grained Reactivity in Angular: Part 3 - Resources](https://dev.to/mihamulec/fun-grained-reactivity-in-angular-part-3-client-side-http-57g4)
555
+ `getUntracked` reads without subscribing important inside guards where reactivity could cause re-entrancy.