@mmstack/resource 22.1.0 → 22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmstack/resource",
3
- "version": "22.1.0",
3
+ "version": "22.1.2",
4
4
  "keywords": [
5
5
  "angular",
6
6
  "signals",
@@ -1,6 +1,6 @@
1
- import { HttpResponse, HttpInterceptorFn, HttpRequest, HttpContext, HttpResourceRef, HttpHeaders, HttpResourceRequest, HttpResourceOptions } from '@angular/common/http';
1
+ import { HttpResponse, HttpInterceptorFn, HttpRequest, HttpContext, HttpResourceOptions, HttpResourceRequest, HttpResourceRef, HttpHeaders } from '@angular/common/http';
2
2
  import { Signal, Injector, Provider, ResourceRef, InjectionToken, WritableSignal, ValueEqualityFn } from '@angular/core';
3
- import { RegisterOptions } from '@mmstack/primitives';
3
+ import { PauseOption } from '@mmstack/primitives';
4
4
 
5
5
  type StoredEntry<T> = Omit<CacheEntry<T>, 'timeout'>;
6
6
  type CacheDB<T> = {
@@ -51,8 +51,11 @@ type CacheEntry<T> = {
51
51
  updated: number;
52
52
  stale: number;
53
53
  useCount: number;
54
+ /** Timestamp of the last read/write — drives LRU eviction. */
55
+ lastAccessed: number;
54
56
  expiresAt: number;
55
- timeout: ReturnType<typeof setTimeout>;
57
+ /** Absent for non-finite/over-int32 TTLs — those rely on lazy expiry instead. */
58
+ timeout?: ReturnType<typeof setTimeout>;
56
59
  key: string;
57
60
  };
58
61
  /**
@@ -73,9 +76,27 @@ declare class Cache<T> {
73
76
  private readonly internal;
74
77
  private readonly cleanupOpt;
75
78
  private readonly id;
79
+ /** True once async hydration from the persistence layer has completed (or was empty). */
80
+ private hydrated;
81
+ /** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
82
+ private readonly hydrationTombstones;
83
+ private readonly hitCount;
84
+ private readonly missCount;
76
85
  /**
77
- * Destroys the cache instance, cleaning up any resources used by the cache.
78
- * This method is called automatically when the cache instance is garbage collected.
86
+ * Read-only cache statistics for debugging/observability entry count plus
87
+ * request-level hit/miss counters (counted on direct lookups, e.g. the cache
88
+ * interceptor's, not on every reactive signal read). Render it in a debug
89
+ * panel; it intentionally exposes no way to mutate the cache.
90
+ */
91
+ readonly stats: Signal<{
92
+ size: number;
93
+ hits: number;
94
+ misses: number;
95
+ }>;
96
+ /**
97
+ * Destroys the cache instance, clearing the cleanup interval and closing the
98
+ * cross-tab channel. Called automatically when the providing injector is destroyed
99
+ * (wired up by `provideQueryCache`); call it manually for caches you construct yourself.
79
100
  */
80
101
  readonly destroy: () => void;
81
102
  private readonly broadcast;
@@ -97,9 +118,11 @@ declare class Cache<T> {
97
118
  }, db?: Promise<CacheDB<T>>);
98
119
  /** @internal */
99
120
  private getInternal;
121
+ /** @internal Imperative access bookkeeping for LRU eviction. */
122
+ private touch;
100
123
  /**
101
- * Retrieves a cache entry without affecting its usage count (for LRU). This is primarily
102
- * for internal use or debugging.
124
+ * Retrieves a cache entry directly (non-reactively), updating its access bookkeeping
125
+ * for LRU eviction.
103
126
  * @internal
104
127
  * @param key - The key of the entry to retrieve.
105
128
  * @returns The cache entry, or `null` if not found or expired.
@@ -130,13 +153,27 @@ declare class Cache<T> {
130
153
  /**
131
154
  * Stores a value in the cache.
132
155
  *
156
+ * NOTE: cached values are shared by reference across all consumers (current and
157
+ * future cache hits, persistence, cross-tab sync) — do not mutate a value after
158
+ * storing it or after reading it from the cache.
159
+ *
133
160
  * @param key - The key under which to store the value.
134
161
  * @param value - The value to store.
135
162
  * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
136
163
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
164
+ * @param persist - (Optional) Whether to also write the entry to the persistence layer (IndexedDB). Defaults to `false`.
137
165
  */
138
166
  store(key: string, value: T, staleTime?: number, ttl?: number, persist?: boolean): void;
139
167
  private storeInternal;
168
+ /**
169
+ * @internal
170
+ * Inserts an entry that already carries ABSOLUTE timestamps — hydration from the
171
+ * persistence layer and cross-tab sync messages. Never re-anchors freshness to
172
+ * `Date.now()`, never persists, never broadcasts.
173
+ */
174
+ private restoreInternal;
175
+ /** @internal Shared writer: arms the expiry timer only within the safe delay range. */
176
+ private setEntry;
140
177
  /**
141
178
  * Invalidates (removes) a cache entry.
142
179
  *
@@ -162,7 +199,12 @@ declare class Cache<T> {
162
199
  */
163
200
  invalidateWhere(predicate: (key: string) => boolean): number;
164
201
  private invalidateInternal;
165
- /** @internal */
202
+ /**
203
+ * Removes EVERY entry — memory, persisted rows, and (via broadcast) other tabs.
204
+ * Call on logout/auth changes so no prior user's responses survive.
205
+ */
206
+ clear(): void;
207
+ /** @internal Drops expired entries, then enforces `maxSize` by the configured strategy. */
166
208
  private cleanup;
167
209
  }
168
210
  /**
@@ -256,6 +298,10 @@ declare function injectQueryCache<TRaw = unknown>(injector?: Injector): Cache<Ht
256
298
  * is made to the server, and the response is cached according to the configured TTL and staleness.
257
299
  * The interceptor also respects `Cache-Control` headers from the server.
258
300
  *
301
+ * Cache-enabled requests are single-flighted per cache key: N concurrent consumers of
302
+ * the same missing/stale entry share ONE network request. Non-cached requests are not
303
+ * touched — pair with `createDedupeRequestsInterceptor` to coalesce those as well.
304
+ *
259
305
  * @param allowedMethods - An array of HTTP methods for which caching should be enabled.
260
306
  * Defaults to `['GET', 'HEAD', 'OPTIONS']`.
261
307
  *
@@ -436,6 +482,12 @@ declare function noDedupe(ctx?: HttpContext): HttpContext;
436
482
  * only the first request will be sent to the server. Subsequent requests will
437
483
  * receive the response from the first request.
438
484
  *
485
+ * Relationship to `createCacheInterceptor`: the cache interceptor has built-in
486
+ * single-flight for CACHE-ENABLED requests (keyed by the cache key). This interceptor
487
+ * covers everything the cache doesn't see — non-cached resources, plain HttpClient
488
+ * calls, DELETEs — keyed by the request hash. Installing both is the recommended
489
+ * setup; where they overlap, this one degrades to a no-op passthrough.
490
+ *
439
491
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
440
492
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
441
493
  * @param keyFn - Optional function to compute the dedupe key from a request.
@@ -466,6 +518,29 @@ declare function noDedupe(ctx?: HttpContext): HttpContext;
466
518
  */
467
519
  declare function createDedupeRequestsInterceptor(allowed?: string[], keyFn?: (req: HttpRequest<unknown>) => string): HttpInterceptorFn;
468
520
 
521
+ /**
522
+ * Refresh configuration for a query resource.
523
+ * - a `number` is shorthand for `{ interval: number }` (poll every n milliseconds)
524
+ * - the object form composes polling with event-driven refresh triggers
525
+ */
526
+ type RefreshOptions = number | {
527
+ /**
528
+ * Poll interval in milliseconds. Omit (or 0) for no polling — useful when only
529
+ * the event-driven triggers below are wanted.
530
+ */
531
+ interval?: number;
532
+ /**
533
+ * Reload when the page becomes visible again (tab refocused, window restored).
534
+ * @default false
535
+ */
536
+ onFocus?: boolean;
537
+ /**
538
+ * Reload when the browser comes back online.
539
+ * @default false
540
+ */
541
+ onReconnect?: boolean;
542
+ };
543
+
469
544
  type RetryOptions = number | {
470
545
  max?: number;
471
546
  backoff?: number;
@@ -473,17 +548,17 @@ type RetryOptions = number | {
473
548
 
474
549
  /**
475
550
  * Auto-registration into the nearest transition scope, as a resource OPTION:
476
- * - `true` — register for the pending indicator + hold-stale (does NOT block first paint);
477
- * - `{ suspends: true }` register as *suspending* (the boundary holds its placeholder until
478
- * this resource has a value), i.e. full Suspense;
479
- * - `{ suspends: false }` same as `true`;
551
+ * - `'suspend'` — register as *suspending*: the boundary holds its placeholder until this
552
+ * resource has a value (full Suspense). The right choice for data the subtree can't render without;
553
+ * - `'indicator'` register for the pending indicator + hold-stale only (does NOT block first
554
+ * paint). The right choice for in-region data: the boundary shows the held value with `aria-busy`;
480
555
  * - `false` / omitted — don't register.
481
556
  *
482
557
  * Defaultable via `provideResourceOptions` / `provideQueryResourceOptions` and overridable
483
558
  * (including opting out with `false`) per call — so a dev can make "all queries participate in
484
559
  * transitions" the default and turn it off for the odd one.
485
560
  */
486
- type TransitionRegistration = boolean | RegisterOptions;
561
+ type TransitionRegistration = false | 'indicator' | 'suspend';
487
562
  /** Options common to every resource kind (the base layer for the options-injection system). */
488
563
  type CommonResourceOptions = {
489
564
  /** Auto-registration into the nearest transition scope. */
@@ -548,6 +623,17 @@ type ResourceCacheOptions = true | {
548
623
  * @default false - By default, the cache entry is not persisted.
549
624
  */
550
625
  persist?: boolean;
626
+ /**
627
+ * Request headers whose values should partition the cache key — e.g.
628
+ * `['Authorization']` gives each user their own entries, `['Accept-Language']`
629
+ * separates per-language responses. Header values are one-way digested into the
630
+ * key (never embedded raw), so secrets don't end up in persisted/broadcast keys.
631
+ * Ignored when a custom `hash` function is provided (it owns the key entirely).
632
+ *
633
+ * Note: still call `cache.clear()` on logout — the previous user's entries are
634
+ * unreachable under the new key but linger until their TTL.
635
+ */
636
+ varyHeaders?: string[];
551
637
  };
552
638
  /**
553
639
  * Options for configuring a `queryResource`. Extends Angular's
@@ -574,10 +660,19 @@ type QueryResourceOptions<TResult, TRaw = TResult> = HttpResourceOptions<TResult
574
660
  */
575
661
  keepPrevious?: boolean;
576
662
  /**
577
- * The refresh interval, in milliseconds. If provided, the resource will automatically
578
- * refresh its data at the specified interval.
663
+ * Automatic refresh behavior. A number polls every n milliseconds; the object form
664
+ * composes polling with event-driven triggers:
665
+ *
666
+ * ```ts
667
+ * refresh: 30_000 // poll every 30s
668
+ * refresh: { onFocus: true, onReconnect: true } // refetch on tab refocus / back-online
669
+ * refresh: { interval: 60_000, onFocus: true } // both
670
+ * ```
671
+ *
672
+ * Triggers respect the resource's disabled/paused state (no refetch while
673
+ * offline, circuit-open, or paused).
579
674
  */
580
- refresh?: number;
675
+ refresh?: RefreshOptions;
581
676
  /**
582
677
  * Called on every failed attempt, including each retry.
583
678
  *
@@ -593,6 +688,20 @@ type QueryResourceOptions<TResult, TRaw = TResult> = HttpResourceOptions<TResult
593
688
  * Options for enabling and configuring caching for the resource.
594
689
  */
595
690
  cache?: ResourceCacheOptions;
691
+ /**
692
+ * Opt-in automatic pausing (off by default — existing behavior unchanged):
693
+ * - `true` — pause whenever the surrounding Activity boundary (`MmActivity` /
694
+ * `providePaused` from `@mmstack/primitives`) is paused. Outside a boundary this
695
+ * is a no-op, so it's safe to set app-wide via `provideQueryResourceOptions`.
696
+ * - a `() => boolean` predicate (a `Signal<boolean>` qualifies) — pause while it
697
+ * returns `true`.
698
+ *
699
+ * Pausing has the same semantics as returning `ctx.paused` from the request fn:
700
+ * the resource HOLDS its current value and last request (no refetch on resume if
701
+ * the request is unchanged) and stops background work (polling, focus/reconnect
702
+ * triggers). The two compose — either source can pause the resource.
703
+ */
704
+ pause?: PauseOption;
596
705
  /**
597
706
  * Comparison of request object
598
707
  */
@@ -619,6 +728,9 @@ declare function provideQueryResourceOptions(valueOrFn: Partial<QueryResourceOpt
619
728
  * }
620
729
  * });
621
730
  * ```
731
+ *
732
+ * Note: a PAUSED resource also reports `'no-request'` — it holds its previous value
733
+ * and request, but no request is currently active.
622
734
  */
623
735
  type DisabledReason = 'offline' | 'circuit-open' | 'no-request';
624
736
  /**
@@ -677,7 +789,10 @@ type QueryResourceRef<TResult> = Omit<HttpResourceRef<TResult>, 'headers' | 'sta
677
789
  disabledReason: Signal<DisabledReason | null>;
678
790
  /**
679
791
  * Prefetches data for the resource, populating the cache if caching is enabled. This can be
680
- * used to proactively load data before it's needed. If a slow connection is detected, prefetching is skipped.
792
+ * used to proactively load data before it's needed.
793
+ *
794
+ * Resolves immediately without fetching when caching is disabled or a slow
795
+ * connection is detected (prefetching would compete with user-initiated requests).
681
796
  *
682
797
  * @param req - Optional partial request parameters to use for the prefetch. This allows you
683
798
  * to prefetch data with different parameters than the main resource request.
@@ -742,6 +857,85 @@ declare function queryResource<TResult, TRaw = TResult>(request: ResourceRequest
742
857
  */
743
858
  declare function queryResource<TResult, TRaw = TResult>(request: ResourceRequestFn, options?: QueryResourceOptions<TResult, TRaw>): QueryResourceRef<TResult | undefined>;
744
859
 
860
+ /**
861
+ * Context passed to an infinite query's request fn: the {@link RequestContext}
862
+ * (so the fn can return `ctx.paused` to pause the resource, exactly like
863
+ * `queryResource`) plus the `pageParam` addressing the page to load.
864
+ */
865
+ type InfiniteRequestContext<TPageParam> = RequestContext & {
866
+ pageParam: TPageParam;
867
+ };
868
+ /**
869
+ * Options for {@link infiniteQueryResource}. Extends {@link QueryResourceOptions}
870
+ * (minus `defaultValue` — the aggregate value is always the `pages` array) with the
871
+ * pagination contract.
872
+ */
873
+ type InfiniteQueryResourceOptions<TPage, TRaw = TPage, TPageParam = unknown> = Omit<QueryResourceOptions<TPage, TRaw>, 'defaultValue'> & {
874
+ /** The page param the FIRST page is requested with (e.g. `0`, `1`, or a cursor seed). */
875
+ initialPageParam: TPageParam;
876
+ /**
877
+ * Derives the NEXT page's param from the freshly loaded page (and all pages so far).
878
+ * Return `null`/`undefined` to signal "no more pages" — `hasNextPage` flips false
879
+ * and `fetchNextPage()` becomes a no-op.
880
+ *
881
+ * @example
882
+ * // cursor-based
883
+ * getNextPageParam: (last) => last.nextCursor;
884
+ * // offset-based
885
+ * getNextPageParam: (last, all) => (last.items.length < PAGE_SIZE ? null : all.length);
886
+ */
887
+ getNextPageParam: (lastPage: NoInfer<TPage>, allPages: NoInfer<TPage>[]) => TPageParam | null | undefined;
888
+ };
889
+ /**
890
+ * A paginated query resource. `pages` accumulates every loaded page in order;
891
+ * `fetchNextPage()` loads the next one (no-op while one is in flight or when
892
+ * exhausted). Inherits the underlying query's `status`/`error`/`isLoading` and
893
+ * its features (cache, retry, circuit breaker, refresh).
894
+ */
895
+ type InfiniteQueryResourceRef<TPage> = {
896
+ /** Every page loaded so far, in load order. */
897
+ pages: Signal<TPage[]>;
898
+ /** `true` once the first page is in and `getNextPageParam` keeps producing params. */
899
+ hasNextPage: Signal<boolean>;
900
+ /** `true` while a page request beyond the first is in flight. */
901
+ isFetchingNextPage: Signal<boolean>;
902
+ /** The underlying query's loading state (first page + subsequent pages). */
903
+ isLoading: Signal<boolean>;
904
+ status: QueryResourceRef<TPage | undefined>['status'];
905
+ error: QueryResourceRef<TPage | undefined>['error'];
906
+ /** Loads the next page. No-op while loading or when `hasNextPage()` is false. */
907
+ fetchNextPage: () => void;
908
+ /** Reloads the CURRENT page param — the freshly loaded page replaces its slot. */
909
+ reload: () => boolean;
910
+ /** Drops all pages and refetches from `initialPageParam`. */
911
+ reset: () => void;
912
+ destroy: () => void;
913
+ };
914
+ /**
915
+ * Creates a paginated HTTP resource over {@link queryResource}: one page request at a
916
+ * time, accumulated into a `pages` signal — cursor- and offset-based pagination both
917
+ * fit through `getNextPageParam`. Each page request inherits the full queryResource
918
+ * feature set (caching per page, retries, circuit breaker, refresh triggers).
919
+ *
920
+ * @example
921
+ * ```ts
922
+ * const posts = infiniteQueryResource<PostPage, PostPage, number>(
923
+ * ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
924
+ * {
925
+ * initialPageParam: 0,
926
+ * getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
927
+ * cache: true,
928
+ * },
929
+ * );
930
+ *
931
+ * // template:
932
+ * // @for (page of posts.pages(); track $index) { ... }
933
+ * // <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">More</button>
934
+ * const flat = computed(() => posts.pages().flatMap((p) => p.items));
935
+ * ```
936
+ */
937
+ declare function infiniteQueryResource<TPage, TRaw = TPage, TPageParam = unknown>(request: (ctx: InfiniteRequestContext<TPageParam>) => HttpResourceRequest | string | undefined | typeof PAUSED, options: InfiniteQueryResourceOptions<TPage, TRaw, TPageParam>): InfiniteQueryResourceRef<TPage>;
938
+
745
939
  /**
746
940
  * A reference to a manually triggered query resource. Extends
747
941
  * {@link QueryResourceRef} with a `trigger()` method that runs the request
@@ -862,7 +1056,7 @@ type NextRequest<TMethod extends HttpResourceRequest['method'], TMutation> = TMe
862
1056
  * };
863
1057
  * ```
864
1058
  */
865
- type MutationResourceOptions<TResult, TRaw = TResult, TMutation = TResult, TCTX = void, TICTX = TCTX, TError = unknown> = Omit<QueryResourceOptions<TResult, TRaw>, 'equal' | 'onError' | 'keepPrevious' | 'refresh' | 'cache'> & {
1059
+ type MutationResourceOptions<TResult, TRaw = TResult, TMutation = TResult, TCTX = void, TICTX = TCTX, TError = unknown> = Omit<QueryResourceOptions<TResult, TRaw>, 'equal' | 'onError' | 'keepPrevious' | 'refresh' | 'cache' | 'pause'> & {
866
1060
  /**
867
1061
  * A callback function that is called before the mutation request is made.
868
1062
  * @param value The value being mutated (the `body` of the request).
@@ -892,6 +1086,25 @@ type MutationResourceOptions<TResult, TRaw = TResult, TMutation = TResult, TCTX
892
1086
  * @default false
893
1087
  */
894
1088
  queue?: boolean;
1089
+ /**
1090
+ * Cache entries to invalidate after a SUCCESSFUL mutation — the declarative
1091
+ * alternative to calling `injectQueryCache().invalidatePrefix(...)` in `onSuccess`.
1092
+ *
1093
+ * Each string is a URL prefix matched against auto-generated `GET` cache keys
1094
+ * (`GET:${url}:...`): `'/api/posts'` invalidates `/api/posts` with any query params,
1095
+ * plus subpaths like `/api/posts/123` — and all `varyHeaders` variants of each.
1096
+ * Note that plain prefix matching also catches sibling paths sharing the prefix
1097
+ * (`/api/posts-archive`); pass `'/api/posts/'` or the exact URL to narrow.
1098
+ *
1099
+ * Entries keyed by a custom `hash` function follow that function's shape, not the
1100
+ * auto-key shape — invalidate those manually via `injectQueryCache().invalidateWhere`.
1101
+ *
1102
+ * The function form receives the mutation result and the mutated value:
1103
+ * ```ts
1104
+ * invalidates: (saved) => [`/api/posts`, `/api/users/${saved.authorId}`]
1105
+ * ```
1106
+ */
1107
+ invalidates?: string[] | ((value: NoInfer<TResult>, mutation: NoInfer<TMutation>) => string[]);
895
1108
  equal?: ValueEqualityFn<TMutation>;
896
1109
  };
897
1110
  /**
@@ -984,5 +1197,5 @@ type MutationResourceRef<TResult, TMutation = TResult, TICTX = void> = Omit<Quer
984
1197
  */
985
1198
  declare function mutationResource<TResult, TRaw = TResult, TMutation = TResult, TCTX = void, TICTX = TCTX, TMethod extends HttpResourceRequest['method'] = HttpResourceRequest['method']>(request: (params: TMutation) => Omit<NextRequest<TMethod, TMutation>, 'body'> | undefined | void, options0?: MutationResourceOptions<TResult, TRaw, TMutation, TCTX, TICTX>): MutationResourceRef<TResult, TMutation, TICTX>;
986
1199
 
987
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
988
- export type { CommonResourceOptions, DisabledReason, ManualQueryResourceRef, MutationResourceOptions, MutationResourceRef, QueryResourceOptions, QueryResourceRef, RequestContext, ResourceRequestFn, TransitionRegistration };
1200
+ export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
1201
+ export type { CacheEntry, CleanupType, CommonResourceOptions, DisabledReason, InfiniteQueryResourceOptions, InfiniteQueryResourceRef, InfiniteRequestContext, ManualQueryResourceRef, MutationResourceOptions, MutationResourceRef, QueryResourceOptions, QueryResourceRef, RefreshOptions, RequestContext, ResourceRequestFn, TransitionRegistration };