@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/README.md +132 -20
- package/fesm2022/mmstack-resource.mjs +696 -189
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-resource.d.ts +233 -20
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
|
|
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
|
-
|
|
137
|
-
|
|
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`
|
|
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
|
|
364
|
-
cache.
|
|
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
|
-
|
|
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
|
|
|
@@ -439,10 +514,10 @@ import { queryResource } from '@mmstack/resource';
|
|
|
439
514
|
@Component({ selector: 'user-profile', template: `{{ user.value()?.name }}` })
|
|
440
515
|
class UserProfile {
|
|
441
516
|
readonly id = input.required<string>();
|
|
442
|
-
// `register:
|
|
517
|
+
// `register: 'suspend'` registers into the nearest scope (the
|
|
443
518
|
// <mm-suspense> above) and blocks its first paint until a value lands.
|
|
444
519
|
readonly user = queryResource<User>(() => `/api/users/${this.id()}`, {
|
|
445
|
-
register:
|
|
520
|
+
register: 'suspend',
|
|
446
521
|
});
|
|
447
522
|
}
|
|
448
523
|
|
|
@@ -461,8 +536,8 @@ class UserPage {
|
|
|
461
536
|
}
|
|
462
537
|
```
|
|
463
538
|
|
|
464
|
-
- `register:
|
|
465
|
-
- `register:
|
|
539
|
+
- `register: 'indicator'` — register for the **pending indicator + hold-stale**; does _not_ block first paint. The right choice for in-region data: the boundary shows the held value with `aria-busy`, not a placeholder.
|
|
540
|
+
- `register: 'suspend'` — register as **suspending**: the boundary holds its placeholder until this resource has a value (full Suspense). The right choice for data the subtree can't render without.
|
|
466
541
|
- `false` / omitted — don't register.
|
|
467
542
|
|
|
468
543
|
Combine with `keepPrevious: true` so reloads hold the last value instead of flashing empty — then a `<mm-suspense>` shows the placeholder only on the genuine first load, and `startTransition` (from `@mmstack/primitives`) can reveal a multi-resource update in one frame. For navigation, `@mmstack/router-core`'s `<mm-transition-outlet>` keeps the current route on screen until the incoming route's registered resources settle.
|
|
@@ -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**:
|
|
@@ -490,7 +584,7 @@ Common options (`register`, `retry`, `circuitBreaker`, `triggerOnSameRequest`) c
|
|
|
490
584
|
```typescript
|
|
491
585
|
providers: [
|
|
492
586
|
// Layer 1 — applies to every resource kind.
|
|
493
|
-
provideResourceOptions({ retry: { max: 2 }, register:
|
|
587
|
+
provideResourceOptions({ retry: { max: 2 }, register: 'indicator' }),
|
|
494
588
|
// Layer 2 — queries only (inherits + overrides layer 1).
|
|
495
589
|
provideQueryResourceOptions({ circuitBreaker: true }),
|
|
496
590
|
// Layer 2 — mutations only.
|
|
@@ -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
|
-
###
|
|
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
|
-
|
|
534
|
-
cache.
|
|
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
|
|
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
|
|