@mmstack/resource 22.1.6 → 22.2.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/README.md +101 -8
- package/fesm2022/mmstack-resource.mjs +499 -380
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-resource.d.ts +91 -7
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ It's designed to be opt-in feature by feature: starting with `queryResource()` a
|
|
|
26
26
|
- [Pausing a resource](#pausing-a-resource)
|
|
27
27
|
- [Default options (`provideResourceOptions`)](#default-options-provideresourceoptions)
|
|
28
28
|
- [Composition (retry / refresh / keepPrevious)](#composition-retry--refresh--keepprevious)
|
|
29
|
+
- [Testing](#testing)
|
|
29
30
|
- [Recipes](#recipes)
|
|
30
31
|
|
|
31
32
|
## Install
|
|
@@ -36,7 +37,9 @@ npm install @mmstack/resource
|
|
|
36
37
|
|
|
37
38
|
## Quick start
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
A plain `queryResource()` works with nothing but `provideHttpClient()` — an in-memory cache is wired up by default. To turn caching on you add the interceptors (below) and opt resources into it per request. `provideQueryCache()` is **optional**: call it only when you want persistence (IndexedDB), cross-tab sync, or to tune the global TTL/stale defaults — it overrides the in-memory default.
|
|
41
|
+
|
|
42
|
+
Recommended app config — interceptors plus `provideQueryCache()` for a tuned, persistent cache:
|
|
40
43
|
|
|
41
44
|
```typescript
|
|
42
45
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
@@ -311,7 +314,7 @@ After a successful mutation, related query caches usually need refreshing. Inste
|
|
|
311
314
|
|
|
312
315
|
```typescript
|
|
313
316
|
mutationResource((p: Post) => ({ url: '/api/posts', method: 'POST', body: p }), {
|
|
314
|
-
invalidates: ['/api/posts'], // every cached
|
|
317
|
+
invalidates: ['/api/posts'], // every cached entry under /api/posts (any method, params, subpaths, varyHeaders variants)
|
|
315
318
|
});
|
|
316
319
|
|
|
317
320
|
// or derived from the result:
|
|
@@ -320,7 +323,7 @@ mutationResource(request, {
|
|
|
320
323
|
});
|
|
321
324
|
```
|
|
322
325
|
|
|
323
|
-
Strings are URL prefixes matched against
|
|
326
|
+
Strings are URL prefixes matched against the request URL of every cached entry, regardless of HTTP method (so a POST-bodied search cached under the same URL is cleared too). Plain prefix matching also catches sibling paths sharing the prefix (`/api/posts-archive`) — pass `'/api/posts/'` or an exact URL to narrow. Keys a custom `hash` merely *prepends* a namespace to (e.g. a tenant/`sub`) are still matched; keys that abandon the auto shape entirely need an `invalidateMatcher: (urlPrefix) => (key) => boolean` (set per-mutation or globally via `provideMutationResourceOptions`), or manual `injectQueryCache().invalidateWhere`.
|
|
324
327
|
|
|
325
328
|
### Re-firing with an identical body (`triggerOnSameRequest`)
|
|
326
329
|
|
|
@@ -433,7 +436,7 @@ const rows = keyArray(items, (item) => buildRowVm(item), {
|
|
|
433
436
|
|
|
434
437
|
### `provideQueryCache(options?)`
|
|
435
438
|
|
|
436
|
-
|
|
439
|
+
Overrides the shared `Cache` in the root injector. It's optional — without it `queryResource` falls back to an in-memory cache (no persistence, no cross-tab sync). Call `provideQueryCache()` to add IndexedDB persistence, cross-tab sync, or to tune the global TTL/stale defaults.
|
|
437
440
|
|
|
438
441
|
```typescript
|
|
439
442
|
provideQueryCache({
|
|
@@ -476,14 +479,14 @@ With `syncTabs: true`, cache invalidations and updates broadcast via `BroadcastC
|
|
|
476
479
|
|
|
477
480
|
```typescript
|
|
478
481
|
const cache = injectQueryCache<MyResponse>();
|
|
479
|
-
cache.
|
|
480
|
-
cache.invalidatePrefix('GET:/api/posts'); // drop every key under a URL prefix
|
|
482
|
+
cache.invalidateUrlPrefix('/api/posts'); // drop every entry under a URL prefix, any method
|
|
481
483
|
cache.invalidateWhere((key) => key.includes('userId=42')); // arbitrary predicates
|
|
484
|
+
cache.invalidatePrefix('raw-key-prefix'); // match the raw key string from its start
|
|
482
485
|
cache.clear(); // drop EVERYTHING — memory, persisted rows, other tabs
|
|
483
486
|
cache.store(key, value, staleTime, ttl); // imperative write
|
|
484
487
|
```
|
|
485
488
|
|
|
486
|
-
Auto-generated keys have the shape `${method}
|
|
489
|
+
Auto-generated keys have the shape `${method}${SEP}${url}${SEP}${responseType}[${SEP}params][${SEP}body][${SEP}vary]`, where `SEP` is a content-rare control-character delimiter (treat keys as opaque — don't hand-build them). `invalidateUrlPrefix(urlPrefix)` is the common move: it recovers the URL field structurally, so it matches **any** HTTP method and even keys a custom `cache.hash` prepends a namespace to. For a fully-custom key scheme it takes an optional `match` (`(urlPrefix) => (key) => boolean`). Call `clear()` on logout so no prior user's responses survive. For observability there's a read-only `cache.stats()` signal (`{ size, hits, misses }`) — handy for a debug panel; it deliberately exposes no mutation surface.
|
|
487
490
|
|
|
488
491
|
Prefer the declarative [`invalidates`](#declarative-invalidation-invalidates) option on `mutationResource` for the common "mutation succeeded → refresh related queries" case.
|
|
489
492
|
|
|
@@ -657,6 +660,96 @@ Practical consequences:
|
|
|
657
660
|
- **`keepPrevious` works alongside both.** While a retry or refresh is in flight, `value()` is the previous successful result, not `undefined`.
|
|
658
661
|
- **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.
|
|
659
662
|
|
|
663
|
+
## Testing
|
|
664
|
+
|
|
665
|
+
Testing code that uses `queryResource`/`mutationResource` comes down to two things: a deterministic cache and a way to feed mock HTTP responses.
|
|
666
|
+
|
|
667
|
+
### `provideMockQueryCache(options?)`
|
|
668
|
+
|
|
669
|
+
A real in-memory cache built for tests. Unlike `provideQueryCache()` it never touches IndexedDB or `BroadcastChannel`, and it disables the cleanup sweep interval — so it's safe under `vi.useFakeTimers()` / `jest.useFakeTimers()` and leaves no timers pinned between specs. It's a real cache (not a stub), so cache hits behave exactly as in production and you can assert against them.
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
673
|
+
import { TestBed } from '@angular/core/testing';
|
|
674
|
+
import {
|
|
675
|
+
createCacheInterceptor,
|
|
676
|
+
createDedupeRequestsInterceptor,
|
|
677
|
+
provideMockQueryCache,
|
|
678
|
+
} from '@mmstack/resource';
|
|
679
|
+
|
|
680
|
+
beforeEach(() => {
|
|
681
|
+
TestBed.configureTestingModule({
|
|
682
|
+
providers: [
|
|
683
|
+
provideMockQueryCache(),
|
|
684
|
+
provideHttpClient(
|
|
685
|
+
withInterceptors([
|
|
686
|
+
createCacheInterceptor(),
|
|
687
|
+
createDedupeRequestsInterceptor(),
|
|
688
|
+
// your response-mocking interceptor (see below)
|
|
689
|
+
]),
|
|
690
|
+
),
|
|
691
|
+
],
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
> A plain `queryResource` no longer requires any cache provider at all (the in-memory default applies), so `provideMockQueryCache()` is only needed when you want the deterministic, timer-free cache for asserting caching behavior.
|
|
697
|
+
|
|
698
|
+
### `provideMockResourceSensors(options?)`
|
|
699
|
+
|
|
700
|
+
Resources auto-pause when the network drops or the page is hidden. To drive that behavior in a test — instead of relying on the real `navigator.onLine` / `document.visibilityState` — provide controllable sensors. Pass your own writable signals to toggle state mid-test; omit them for a static online + visible environment.
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
import { signal } from '@angular/core';
|
|
704
|
+
import { provideMockResourceSensors } from '@mmstack/resource';
|
|
705
|
+
|
|
706
|
+
const online = signal(true);
|
|
707
|
+
|
|
708
|
+
TestBed.configureTestingModule({
|
|
709
|
+
providers: [provideMockResourceSensors({ networkStatus: online })],
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ...later in the test
|
|
713
|
+
online.set(false); // the resource sees the network drop and disables
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
### Mocking HTTP responses
|
|
717
|
+
|
|
718
|
+
For most tests — a cold cache or a resource that doesn't cache — Angular's standard `provideHttpClientTesting()` + `HttpTestingController` works out of the box, because a cache miss flows through the interceptor chain to the testing backend like any other request.
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
import {
|
|
722
|
+
provideHttpClient,
|
|
723
|
+
provideHttpClientTesting,
|
|
724
|
+
} from '@angular/common/http/testing';
|
|
725
|
+
import { HttpTestingController } from '@angular/common/http/testing';
|
|
726
|
+
|
|
727
|
+
const ctrl = TestBed.inject(HttpTestingController);
|
|
728
|
+
ctrl.expectOne('https://example.com/posts').flush([{ id: 1 }]);
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
When you specifically want to assert that a **cache hit short-circuits before the network**, `HttpTestingController` won't see the second request (that's the point). For those cases set the mock response on the request's `HttpContext` and read it from a tiny interceptor, which lets you count how many requests actually reached the network:
|
|
732
|
+
|
|
733
|
+
```typescript
|
|
734
|
+
import { HttpContextToken, type HttpInterceptorFn } from '@angular/common/http';
|
|
735
|
+
import { HttpResponse } from '@angular/common/http';
|
|
736
|
+
import { of } from 'rxjs';
|
|
737
|
+
|
|
738
|
+
const MOCK = new HttpContextToken<() => unknown>(() => () => null);
|
|
739
|
+
|
|
740
|
+
const mockInterceptor: HttpInterceptorFn = (req) =>
|
|
741
|
+
of(new HttpResponse({ body: req.context.get(MOCK)(), status: 200 }));
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
Pass `context` on the request and reuse the same cache key (same URL) to observe the hit:
|
|
745
|
+
|
|
746
|
+
```typescript
|
|
747
|
+
queryResource(
|
|
748
|
+
() => ({ url, context: new HttpContext().set(MOCK, () => ({ ok: true })) }),
|
|
749
|
+
{ cache: { staleTime: 10_000 } },
|
|
750
|
+
);
|
|
751
|
+
```
|
|
752
|
+
|
|
660
753
|
## Recipes
|
|
661
754
|
|
|
662
755
|
### Optimistic update with rollback
|
|
@@ -669,7 +762,7 @@ Declarative — the common case:
|
|
|
669
762
|
|
|
670
763
|
```typescript
|
|
671
764
|
mutationResource((p: Post) => ({ url: '/posts', method: 'POST', body: p }), {
|
|
672
|
-
invalidates: ['/posts'], // every cached
|
|
765
|
+
invalidates: ['/posts'], // every cached entry under /posts, any method + params + subpaths + vary variants
|
|
673
766
|
});
|
|
674
767
|
```
|
|
675
768
|
|