@mmstack/resource 19.7.0 → 19.7.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
@@ -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
- Two-step setup: provide the cache + interceptors in your app config, then create resources in your services or components.
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';
@@ -433,7 +436,7 @@ const rows = keyArray(items, (item) => buildRowVm(item), {
433
436
 
434
437
  ### `provideQueryCache(options?)`
435
438
 
436
- Registers the shared `Cache` in the root injector.
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({
@@ -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
@@ -535,7 +535,7 @@ class Cache {
535
535
  };
536
536
  if (this.cleanupOpt.maxSize <= 0)
537
537
  throw new Error('maxSize must be greater than 0');
538
- // a non-finite checkInterval disables the sweeper entirely (used by the shared NoopCache)
538
+ // a non-finite checkInterval disables the sweeper entirely (used by provideMockQueryCache)
539
539
  const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
540
540
  ? setInterval(() => {
541
541
  this.cleanup();
@@ -905,7 +905,48 @@ class Cache {
905
905
  this.internal.set(new Map(keep));
906
906
  }
907
907
  }
908
- const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
908
+ const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE', {
909
+ // Memory-only default so a plain queryResource works with zero config. No
910
+ // IndexedDB / BroadcastChannel — keeps it SSR-safe and request-isolated under
911
+ // SSR (root injector is per-request). provideQueryCache() overrides this to
912
+ // layer on persistence / cross-tab sync / global TTL tuning.
913
+ providedIn: 'root',
914
+ factory: () => {
915
+ const cache = new Cache();
916
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
917
+ return cache;
918
+ },
919
+ });
920
+ /**
921
+ * Provides a deterministic, in-memory `QueryCache` for unit tests.
922
+ *
923
+ * Unlike {@link provideQueryCache} this never touches IndexedDB or BroadcastChannel
924
+ * and disables the cleanup sweep interval (`checkInterval: Infinity`), so it plays
925
+ * nicely with `vi.useFakeTimers()`. It's a real cache (not a stub), so you can
926
+ * assert cache hits via {@link injectQueryCache} / its `stats` signal.
927
+ *
928
+ * @example
929
+ * TestBed.configureTestingModule({
930
+ * providers: [
931
+ * provideMockQueryCache(),
932
+ * provideHttpClient(withInterceptors([createCacheInterceptor()])),
933
+ * ],
934
+ * });
935
+ */
936
+ function provideMockQueryCache(opt) {
937
+ return {
938
+ provide: CLIENT_CACHE_TOKEN,
939
+ useFactory: () => {
940
+ const cache = new Cache(opt?.ttl, opt?.staleTime, {
941
+ type: 'lru',
942
+ maxSize: 200,
943
+ checkInterval: Infinity,
944
+ });
945
+ inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
946
+ return cache;
947
+ },
948
+ };
949
+ }
909
950
  /**
910
951
  * Provides the instance of the QueryCache for queryResource. This should probably be called
911
952
  * in your application's root configuration, but can also be overriden with component/module providers.
@@ -968,15 +1009,12 @@ function provideQueryCache(opt) {
968
1009
  return null;
969
1010
  }
970
1011
  };
971
- // version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
972
- // push entries into each other's caches (the `version` option only fences IndexedDB)
973
1012
  const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
974
1013
  return {
975
1014
  provide: CLIENT_CACHE_TOKEN,
976
1015
  useFactory: () => {
977
1016
  const onServer = inject(PLATFORM_ID) === 'server';
978
- // no IndexedDB / BroadcastChannel on the server — each request gets an
979
- // isolated, request-lived, memory-only cache
1017
+ // no IndexedDB / BroadcastChannel on the server
980
1018
  const syncTabsOpt = !onServer && opt?.syncTabs
981
1019
  ? {
982
1020
  id: syncChannelId,
@@ -1016,24 +1054,6 @@ function provideQueryCache(opt) {
1016
1054
  },
1017
1055
  };
1018
1056
  }
1019
- class NoopCache extends Cache {
1020
- constructor() {
1021
- // Infinity checkInterval → no sweep interval is ever armed, so the shared
1022
- // instance below never pins a timer
1023
- super(undefined, undefined, {
1024
- type: 'lru',
1025
- maxSize: 200,
1026
- checkInterval: Infinity,
1027
- });
1028
- }
1029
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
1030
- store(_, __, ___ = super.staleTime, ____ = super.ttl) {
1031
- // noop
1032
- }
1033
- }
1034
- // one shared instance — minting a NoopCache per injectQueryCache() miss would leak
1035
- // an instance (and previously an interval) on every prod call without a provider
1036
- let NOOP_CACHE;
1037
1057
  /**
1038
1058
  * Injects the `QueryCache` instance that is used within queryResource.
1039
1059
  * Allows for direct modification of cached data, but is mostly meant for internal use.
@@ -1058,18 +1078,8 @@ let NOOP_CACHE;
1058
1078
  */
1059
1079
  function injectQueryCache(injector) {
1060
1080
  const cache = injector
1061
- ? injector.get(CLIENT_CACHE_TOKEN, null, {
1062
- optional: true,
1063
- })
1064
- : inject(CLIENT_CACHE_TOKEN, {
1065
- optional: true,
1066
- });
1067
- if (!cache) {
1068
- if (isDevMode())
1069
- throw new Error('Cache not provided, please add provideQueryCache() to providers array');
1070
- else
1071
- return (NOOP_CACHE ??= new NoopCache());
1072
- }
1081
+ ? injector.get(CLIENT_CACHE_TOKEN)
1082
+ : inject(CLIENT_CACHE_TOKEN);
1073
1083
  return cache;
1074
1084
  }
1075
1085
  /**
@@ -1977,6 +1987,33 @@ function injectNetworkStatus() {
1977
1987
  function injectPageVisibility() {
1978
1988
  return inject(ResourceSensors).pageVisibility;
1979
1989
  }
1990
+ /**
1991
+ * Provides controllable {@link ResourceSensors} for unit tests, letting you drive a
1992
+ * resource's offline / page-hidden behavior deterministically instead of relying on
1993
+ * the real `navigator.onLine` / `document.visibilityState`.
1994
+ *
1995
+ * Pass your own writable signals to toggle state mid-test; omit them for a static
1996
+ * online + visible environment.
1997
+ *
1998
+ * @example
1999
+ * import { signal } from '@angular/core';
2000
+ *
2001
+ * const online = signal(true);
2002
+ * TestBed.configureTestingModule({
2003
+ * providers: [provideMockResourceSensors({ networkStatus: online })],
2004
+ * });
2005
+ * // ...later in the test
2006
+ * online.set(false); // the resource now sees the network as down
2007
+ */
2008
+ function provideMockResourceSensors(opt) {
2009
+ return {
2010
+ provide: ResourceSensors,
2011
+ useValue: {
2012
+ networkStatus: opt?.networkStatus ?? signal(true),
2013
+ pageVisibility: opt?.pageVisibility ?? signal('visible'),
2014
+ },
2015
+ };
2016
+ }
1980
2017
 
1981
2018
  function toResourceObject(res) {
1982
2019
  return {
@@ -2473,6 +2510,57 @@ function injectMutationResourceOptions(injector) {
2473
2510
  ? injector.get(MUTATION_RESOURCE_OPTIONS)
2474
2511
  : inject(MUTATION_RESOURCE_OPTIONS);
2475
2512
  }
2513
+ /**
2514
+ * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
2515
+ * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
2516
+ * It does *not* cache responses and does not provide a `value` signal. Instead, it focuses on
2517
+ * managing the mutation lifecycle (pending, error, success) and provides callbacks for handling
2518
+ * these states.
2519
+ *
2520
+ * @param request A function that returns the base `HttpResourceRequest` to be made. This function is called reactively. The parameter is the mutation value provided by the `mutate` method.
2521
+ * @param options Configuration options for the mutation resource. This includes callbacks
2522
+ * for `onMutate`, `onError`, `onSuccess`, and `onSettled`.
2523
+ * @typeParam TResult - The type of the expected result from the mutation.
2524
+ * @typeParam TRaw - The raw response type from the HTTP request (defaults to TResult).
2525
+ * @typeParam TMutation - The type of the mutation value (the request body).
2526
+ * @typeParam TICTX - The type of the initial context value passed to `onMutate`.
2527
+ * @typeParam TCTX - The type of the context value returned by `onMutate`.
2528
+ * @returns A `MutationResourceRef` instance, which provides methods for triggering the mutation
2529
+ * and observing its status.
2530
+ *
2531
+ * @example
2532
+ * ```ts
2533
+ * // Basic PATCH mutation
2534
+ * const updateUser = mutationResource<User, User, Partial<User>>(
2535
+ * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2536
+ * {
2537
+ * onSuccess: (saved) => toast.success(`Updated ${saved.name}`),
2538
+ * onError: (err) => toast.error(err),
2539
+ * },
2540
+ * );
2541
+ *
2542
+ * updateUser.mutate({ name: 'Alice' });
2543
+ * ```
2544
+ *
2545
+ * @example
2546
+ * ```ts
2547
+ * // Optimistic update with rollback via the `ctx` returned from `onMutate`
2548
+ * const updateUser = mutationResource<User, User, Partial<User>, { prev: User | null }>(
2549
+ * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2550
+ * {
2551
+ * onMutate: (patch) => {
2552
+ * const prev = current();
2553
+ * current.update((u) => (u ? { ...u, ...patch } : u));
2554
+ * return { prev };
2555
+ * },
2556
+ * onError: (_err, { prev }) => current.set(prev),
2557
+ * },
2558
+ * );
2559
+ * ```
2560
+ */
2561
+ // The request callback maps the mutation value into an HTTP request. `body` is a
2562
+ // free-form request field (Angular's `body?: unknown`), independent of `TMutation`
2563
+ // and optional — so bodyless POSTs and transforms (e.g. params → `FormData`) are fine.
2476
2564
  function mutationResource(request, options0 = {}) {
2477
2565
  // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
2478
2566
  const globalOpts = injectResourceOptions(options0.injector);
@@ -2717,5 +2805,5 @@ function mutationResource(request, options0 = {}) {
2717
2805
  * Generated bundle index. Do not edit.
2718
2806
  */
2719
2807
 
2720
- export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2808
+ export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMockQueryCache, provideMockResourceSensors, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2721
2809
  //# sourceMappingURL=mmstack-resource.mjs.map