@mmstack/resource 21.4.1 → 21.4.3

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
@@ -19,6 +19,7 @@ It's designed to be opt-in feature by feature: starting with `queryResource()` a
19
19
  - [`queryResource`](#queryresource)
20
20
  - [`mutationResource`](#mutationresource)
21
21
  - [`manualQueryResource`](#manualqueryresource)
22
+ - [`infiniteQueryResource`](#infinitequeryresource)
22
23
  - [Caching](#caching)
23
24
  - [Circuit breakers](#circuit-breakers)
24
25
  - [Transitions & Suspense](#transitions--suspense)
@@ -126,15 +127,26 @@ All three return a signal-typed ref — `value()`, `status()`, `error()`, `heade
126
127
 
127
128
  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.
128
129
 
129
- **Default key**: produced by `hashRequest()` (`util/hash-request.ts`). Composition is `${method}:${url}:${responseType}[:${params}][:${body}]` — sorted query params, stable body hashing (incl. `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer` markers). **It does not include headers or `HttpContext`.**
130
+ **Default key**: produced by `hashRequest()` (`util/hash-request.ts`). Composition is `${method}:${url}:${responseType}[:${params}][:${body}]` — sorted query params, stable body hashing (incl. `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer` markers). **It does not include headers or `HttpContext` by default.**
130
131
 
131
- If two requests should _not_ share a cache entry but the default key would collide (e.g. different `Authorization` headers), pass a custom hash:
132
+ If responses differ per header different `Authorization` users, `Accept-Language`, a tenant header opt those headers into the key with `varyHeaders`:
132
133
 
133
134
  ```typescript
134
135
  queryResource<Post>(() => ({ url, headers }), {
135
136
  cache: {
136
- hash: (req) =>
137
- `${hashRequest(req)}:${(req.headers as HttpHeaders | undefined)?.get('Authorization') ?? ''}`,
137
+ varyHeaders: ['Authorization'], // per-user cache entries
138
+ },
139
+ });
140
+ ```
141
+
142
+ Header **values are one-way digested** into the key, never embedded raw — cache keys are persisted to IndexedDB and broadcast across tabs, so secrets must not appear in them. (For the same reason, avoid embedding raw header values in a custom `hash` function.) The exception: known-safe content-negotiation headers (`Accept`, `Accept-Language`, `Content-Language`, `Content-Type`) embed their values raw, keeping keys human-readable. Still call `injectQueryCache().clear()` on logout: the previous user's entries are unreachable under the new key, but linger until their TTL.
143
+
144
+ For full control over the key (ignoring certain params, custom shapes), a custom `hash` remains available and takes precedence over `varyHeaders`:
145
+
146
+ ```typescript
147
+ queryResource<Post>(() => ({ url }), {
148
+ cache: {
149
+ hash: (req) => `posts:${new URL(req.url, location.origin).pathname}`,
138
150
  },
139
151
  });
140
152
  ```
@@ -165,6 +177,8 @@ withInterceptors([
165
177
 
166
178
  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.
167
179
 
180
+ **Do you still need both?** Yes — they cover different requests. The cache interceptor has built-in single-flight for **cache-enabled** requests (N concurrent readers of the same stale/missing key share one revalidation, keyed by the _cache key_, incl. `varyHeaders`/custom `hash`). The dedupe interceptor covers everything the cache doesn't see: non-cached `queryResource`s, plain `HttpClient` calls, `DELETE`s, etc., keyed by the request hash. Where they overlap, the cache interceptor coalesces upstream and dedupe degrades to a no-op passthrough — installing both is always safe.
181
+
168
182
  Both default to intercepting only GET. Pass an array to extend: `createCacheInterceptor(['GET', 'HEAD'])`.
169
183
 
170
184
  To opt a single request out of dedup, attach `noDedupe()` to its context:
@@ -193,7 +207,7 @@ queryResource<TResult, TRaw = TResult>(
193
207
  | ---------------------- | ------------------------------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------- |
194
208
  | `defaultValue` | `TResult` | – | Initial value before the first request resolves. When set, `value()` is `TResult`, not `TResult \| undefined`. |
195
209
  | `keepPrevious` | `boolean` | `false` | Hold the previous `value`, `status`, and `headers` while a refresh is in flight. Powered by `linkedSignal`. |
196
- | `refresh` | `number` (ms) | – | Auto-refetch interval. |
210
+ | `refresh` | `number \| { interval?, onFocus?, onReconnect? }` | – | Auto-refetch: a number polls every n ms; the object form adds event triggers — `onFocus` refetches when the tab becomes visible again, `onReconnect` when the browser comes back online. Triggers respect disabled/paused state. |
197
211
  | `retry` | `number \| { max, backoff }` | `0` | On failure, retry N times with exponential backoff (default 1000ms × 2^n). |
198
212
  | `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. |
199
213
  | `circuitBreaker` | `true \| CircuitBreaker \| { threshold?, timeout?, … }` | off | See [circuit breakers](#circuit-breakers). |
@@ -275,6 +289,23 @@ mutationResource(request, { queue: true });
275
289
 
276
290
  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.
277
291
 
292
+ ### Declarative invalidation (`invalidates`)
293
+
294
+ After a successful mutation, related query caches usually need refreshing. Instead of wiring `injectQueryCache().invalidatePrefix(...)` into `onSuccess` by hand, declare it:
295
+
296
+ ```typescript
297
+ mutationResource((p: Post) => ({ url: '/api/posts', method: 'POST', body: p }), {
298
+ invalidates: ['/api/posts'], // every cached GET under /api/posts (any params, subpaths, varyHeaders variants)
299
+ });
300
+
301
+ // or derived from the result:
302
+ mutationResource(request, {
303
+ invalidates: (saved) => ['/api/posts', `/api/users/${saved.authorId}`],
304
+ });
305
+ ```
306
+
307
+ Strings are URL prefixes matched against auto-generated `GET` keys. Plain prefix matching also catches sibling paths sharing the prefix (`/api/posts-archive`) — pass `'/api/posts/'` or an exact URL to narrow. Entries keyed by a custom `hash` follow that function's shape instead; invalidate those via `injectQueryCache().invalidateWhere`.
308
+
278
309
  ### Re-firing with an identical body (`triggerOnSameRequest`)
279
310
 
280
311
  A mutation is an imperative command, so by default an identical `mutate(body)` while one is in flight is **deduplicated** (double-click protection). When a repeat with the same body _must_ fire — e.g. a "resend" button — set `triggerOnSameRequest: true`, and every `mutate()` fires regardless of whether the body changed.
@@ -313,6 +344,46 @@ onSubmit() {
313
344
 
314
345
  `.trigger()` re-evaluates the `request()` function and fires. Everything else (`value`, `status`, `error`, retry, cache, etc.) works identically.
315
346
 
347
+ ## `infiniteQueryResource`
348
+
349
+ Paginated queries: one page request at a time, accumulated into a `pages` signal. Cursor- and offset-based pagination both fit through `getNextPageParam` — return `null`/`undefined` to signal "no more pages". Each page request inherits the full `queryResource` feature set (per-page caching, retries, circuit breaker, refresh triggers).
350
+
351
+ ```typescript
352
+ const posts = infiniteQueryResource<PostPage, PostPage, number>(
353
+ ({ pageParam }) => ({ url: '/api/posts', params: { page: pageParam } }),
354
+ {
355
+ initialPageParam: 0,
356
+ getNextPageParam: (last, all) => (last.items.length < 20 ? null : all.length),
357
+ cache: true,
358
+ },
359
+ );
360
+ ```
361
+
362
+ ```html
363
+ @for (page of posts.pages(); track $index) {
364
+ @for (post of page.items; track post.id) { ... }
365
+ }
366
+ <button (click)="posts.fetchNextPage()" [disabled]="!posts.hasNextPage()">
367
+ @if (posts.isFetchingNextPage()) { Loading… } @else { Load more }
368
+ </button>
369
+ ```
370
+
371
+ - `fetchNextPage()` is a no-op while a page is in flight or when exhausted.
372
+ - `reload()` refetches the **current** page — the result replaces its slot instead of appending a duplicate.
373
+ - `reset()` drops all pages and refetches from `initialPageParam`.
374
+ - The request fn receives the same context as `queryResource` plus `pageParam`, so pausing works identically: `({ pageParam, paused }) => (active() ? requestFor(pageParam) : paused)`.
375
+
376
+ For rendering, compose with the primitives mappers instead of reaching for a built-in projection — `pages` is a plain signal:
377
+
378
+ ```typescript
379
+ // flat item list across pages
380
+ const items = computed(() => posts.pages().flatMap((p) => p.items));
381
+ // stable per-item mappings: appending page 4 doesn't recreate pages 1-3's row VMs
382
+ const rows = keyArray(items, (item) => buildRowVm(item), {
383
+ key: (item) => item.id,
384
+ });
385
+ ```
386
+
316
387
  ## Caching
317
388
 
318
389
  ### `provideQueryCache(options?)`
@@ -360,12 +431,16 @@ With `syncTabs: true`, cache invalidations and updates broadcast via `BroadcastC
360
431
 
361
432
  ```typescript
362
433
  const cache = injectQueryCache<MyResponse>();
363
- cache.invalidate('GET /api/posts'); // drop one entry by key
364
- cache.invalidateAll(); // drop everything
434
+ cache.invalidate('GET:/api/posts:json'); // drop one entry by exact key
435
+ cache.invalidatePrefix('GET:/api/posts'); // drop every key under a URL prefix
436
+ cache.invalidateWhere((key) => key.includes('userId=42')); // arbitrary predicates
437
+ cache.clear(); // drop EVERYTHING — memory, persisted rows, other tabs
365
438
  cache.store(key, value, staleTime, ttl); // imperative write
366
439
  ```
367
440
 
368
- Useful for "log out invalidate all user-scoped queries" or "mutation succeeds invalidate a specific list."
441
+ Auto-generated keys have the shape `${method}:${url}:${responseType}[:params][:body][:vary]` prefix matching against `GET:${url}` is the common move. 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.
442
+
443
+ Prefer the declarative [`invalidates`](#declarative-invalidation-invalidates) option on `mutationResource` for the common "mutation succeeded → refresh related queries" case.
369
444
 
370
445
  ## Circuit breakers
371
446
 
@@ -483,6 +558,25 @@ class Panel {
483
558
  }
484
559
  ```
485
560
 
561
+ ### Auto-pausing (`pause` option)
562
+
563
+ The manual wiring above is fully automatic with the opt-in `pause` option:
564
+
565
+ ```typescript
566
+ // follow the surrounding Activity boundary (MmActivity / providePaused);
567
+ // a no-op outside one, so this is safe to default app-wide
568
+ readonly data = queryResource<Data>(() => `/api/data/${this.id()}`, {
569
+ pause: true,
570
+ });
571
+
572
+ // or drive it from any predicate / Signal<boolean>
573
+ readonly data = queryResource<Data>(() => `/api/data/${this.id()}`, {
574
+ pause: this.minimized,
575
+ });
576
+ ```
577
+
578
+ Same semantics as `ctx.paused` — value and request held, polling and focus/reconnect triggers stop, no refetch on resume unless the request changed. The two sources compose: either can pause the resource. To make every query in the app Activity-aware, set it once via `provideQueryResourceOptions({ pause: true })`. (Mutations never auto-pause — they're one-off commands; use `queue: true` for deferred execution instead.)
579
+
486
580
  ## Default options (`provideResourceOptions`)
487
581
 
488
582
  Common options (`register`, `retry`, `circuitBreaker`, `triggerOnSameRequest`) can be defaulted app-wide, with a three-layer precedence — **per-call > type-specific provider > common provider**:
@@ -524,21 +618,39 @@ Practical consequences:
524
618
 
525
619
  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.
526
620
 
527
- ### Manual invalidation after a mutation
621
+ ### Invalidation after a mutation
622
+
623
+ Declarative — the common case:
528
624
 
529
625
  ```typescript
530
- const cache = injectQueryCache();
531
626
  mutationResource((p: Post) => ({ url: '/posts', method: 'POST', body: p }), {
627
+ invalidates: ['/posts'], // every cached GET under /posts, params + subpaths + vary variants
628
+ });
629
+ ```
630
+
631
+ Manual — for predicates the URL-prefix form can't express:
632
+
633
+ ```typescript
634
+ const cache = injectQueryCache();
635
+ mutationResource(request, {
532
636
  onSuccess: () => {
533
- // Drop every paginated `GET /posts*` cache entry in one shot.
534
- cache.invalidatePrefix('GET /posts');
535
- // Or, for arbitrary predicates:
536
- // cache.invalidateWhere((key) => key.includes('/posts'));
637
+ cache.invalidatePrefix('GET:/posts'); // auto-keys are `${method}:${url}:...`
638
+ // or: cache.invalidateWhere((key) => key.includes('userId=42'));
537
639
  },
538
640
  });
539
641
  ```
540
642
 
541
- `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.
643
+ `invalidate(key)` drops a single entry, `invalidatePrefix(prefix)` drops every key starting with the prefix, and `invalidateWhere(predicate)` handles anything else. Both bulk variants return the number of entries removed.
644
+
645
+ ### Refetch on tab focus / reconnect
646
+
647
+ ```typescript
648
+ queryResource(() => ({ url: '/api/notifications' }), {
649
+ refresh: { onFocus: true, onReconnect: true },
650
+ });
651
+ ```
652
+
653
+ The user switches back to the tab (or the browser comes back online) → the resource refetches, unless it's disabled or paused. Compose with an interval for "poll while visible, refresh immediately on return": `refresh: { interval: 60_000, onFocus: true }`.
542
654
 
543
655
  ### Prefetch on hover
544
656