@mmstack/resource 21.4.5 → 21.5.0

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
@@ -279,6 +279,22 @@ mutationResource(
279
279
 
280
280
  The `TCTX` returned from `onMutate` flows into `onError` / `onSuccess` / `onSettled`. The optional `initialCtx` second arg to `.mutate(value, initialCtx)` flows into `onMutate` as its second argument.
281
281
 
282
+ ### Awaiting a mutation (`mutateAsync`)
283
+
284
+ `.mutate()` is fire-and-forget. When you need to `await` the outcome — a form submit handler, an async validator — use `.mutateAsync()`, which returns a `Promise` that resolves with the result or rejects with the error. The lifecycle hooks still run exactly as with `.mutate()`.
285
+
286
+ ```typescript
287
+ try {
288
+ const saved = await createPost.mutateAsync(post);
289
+ router.navigate(['/posts', saved.id]);
290
+ } catch (e) {
291
+ if (e instanceof MutationCancelledError) return; // never ran — see below
292
+ toast.error('Failed to save');
293
+ }
294
+ ```
295
+
296
+ If the mutation never completes — superseded by a newer one (latest-wins), dropped from the queue (`clearQueue()` / a `key` change), abandoned on `destroy()`, or its `request()` returned `undefined` — the promise rejects with a `MutationCancelledError` whose `.type` (`MutationCancellationReason`) tells you which. Awaiters that don't expect cancellation should rethrow it. (Plain `.mutate()` has no promise, so it never produces an unhandled rejection.)
297
+
282
298
  ### Queuing
283
299
 
284
300
  By default, calling `.mutate()` while another mutation is in flight starts immediately — concurrent mutations run in parallel. With `queue: true`, mutations are serialized:
@@ -295,7 +311,7 @@ After a successful mutation, related query caches usually need refreshing. Inste
295
311
 
296
312
  ```typescript
297
313
  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)
314
+ invalidates: ['/api/posts'], // every cached entry under /api/posts (any method, params, subpaths, varyHeaders variants)
299
315
  });
300
316
 
301
317
  // or derived from the result:
@@ -304,7 +320,7 @@ mutationResource(request, {
304
320
  });
305
321
  ```
306
322
 
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`.
323
+ 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`.
308
324
 
309
325
  ### Re-firing with an identical body (`triggerOnSameRequest`)
310
326
 
@@ -316,13 +332,42 @@ mutationResource(request, { triggerOnSameRequest: true });
316
332
 
317
333
  A mutation also honours the `register` option — but it registers the **mutation ref itself** into the transition scope (its internal query is never registered), so a `<mm-suspense>`/transition reacts to the mutation's own `pending` state. See [transitions & Suspense](#transitions--suspense).
318
334
 
335
+ ### File uploads & progress (`multipart/form-data`)
336
+
337
+ There's no special upload API — return a `FormData` body and `HttpClient` sets the `multipart/form-data` boundary for you. Opt into upload progress with `reportProgress: true` and read the `progress` signal (an `HttpProgressEvent`).
338
+
339
+ ```typescript
340
+ const upload = mutationResource<UploadResult, UploadResult, FormData>((form) => ({
341
+ url: '/api/upload',
342
+ method: 'POST',
343
+ body: form,
344
+ reportProgress: true, // opt in to progress events
345
+ }));
346
+
347
+ // trigger it:
348
+ const form = new FormData();
349
+ form.append('file', file);
350
+ upload.mutate(form);
351
+
352
+ // derive a percentage in a computed / template:
353
+ readonly pct = computed(() => {
354
+ const p = upload.progress();
355
+ return p?.total ? Math.round((p.loaded / p.total) * 100) : null;
356
+ });
357
+ ```
358
+
359
+ `FormData`, `File`, and `Blob` bodies are hashed structurally for dedup/cache keys (a `File` by name + type + size + lastModified), so distinct files never collide. To re-upload the **same** file while one is already in flight, pair it with `triggerOnSameRequest: true`.
360
+
319
361
  ### Return shape (`MutationResourceRef<T, TMutation>`)
320
362
 
321
- | Member | Type | Notes |
322
- | --------------------------------------------- | --------------------------- | ------------------------------------------------------ |
323
- | `mutate` | `(value, ctx?) => void` | Trigger the mutation. |
324
- | `current` | `Signal<TMutation \| null>` | The value currently being mutated (or `null` if idle). |
325
- | `status` / `error` / `isLoading` / `disabled` | as in `QueryResourceRef` | |
363
+ | Member | Type | Notes |
364
+ | --------------------------------------------- | ------------------------------------------ | ------------------------------------------------------ |
365
+ | `mutate` | `(value, ctx?) => void` | Trigger the mutation (fire-and-forget). |
366
+ | `mutateAsync` | `(value, ctx?) => Promise<TResult>` | Trigger and `await` the result; rejects with the error or a `MutationCancelledError`. |
367
+ | `current` | `Signal<TMutation \| null>` | The value currently being mutated (or `null` if idle). |
368
+ | `progress` | `Signal<HttpProgressEvent \| undefined>` | Upload/download progress when `reportProgress: true`. |
369
+ | `status` / `error` / `isLoading` / `disabled` | as in `QueryResourceRef` | – |
370
+ | `headers` / `statusCode` | as in `QueryResourceRef` | Response metadata, when available. |
326
371
 
327
372
  (Mutations deliberately don't expose `value`, `hasValue`, `set`, `update`, or `prefetch` — those don't make sense for one-off writes.)
328
373
 
@@ -431,14 +476,14 @@ With `syncTabs: true`, cache invalidations and updates broadcast via `BroadcastC
431
476
 
432
477
  ```typescript
433
478
  const cache = injectQueryCache<MyResponse>();
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
479
+ cache.invalidateUrlPrefix('/api/posts'); // drop every entry under a URL prefix, any method
436
480
  cache.invalidateWhere((key) => key.includes('userId=42')); // arbitrary predicates
481
+ cache.invalidatePrefix('raw-key-prefix'); // match the raw key string from its start
437
482
  cache.clear(); // drop EVERYTHING — memory, persisted rows, other tabs
438
483
  cache.store(key, value, staleTime, ttl); // imperative write
439
484
  ```
440
485
 
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.
486
+ 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.
442
487
 
443
488
  Prefer the declarative [`invalidates`](#declarative-invalidation-invalidates) option on `mutationResource` for the common "mutation succeeded → refresh related queries" case.
444
489
 
@@ -624,7 +669,7 @@ Declarative — the common case:
624
669
 
625
670
  ```typescript
626
671
  mutationResource((p: Post) => ({ url: '/posts', method: 'POST', body: p }), {
627
- invalidates: ['/posts'], // every cached GET under /posts, params + subpaths + vary variants
672
+ invalidates: ['/posts'], // every cached entry under /posts, any method + params + subpaths + vary variants
628
673
  });
629
674
  ```
630
675