@mmstack/resource 20.8.4 → 20.8.6

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:
@@ -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
 
@@ -2400,6 +2400,23 @@ function manualQueryResource(request, options) {
2400
2400
  }
2401
2401
 
2402
2402
  const NULL_VALUE = Symbol('@mmstack/resource:null');
2403
+ /**
2404
+ * Rejection reason for a {@link MutationResourceRef.mutateAsync} promise whose
2405
+ * mutation never completed. The {@link MutationCancelledError.type} discriminant
2406
+ * carries the cause ({@link MutationCancellationReason}); the message is a
2407
+ * human-readable elaboration of it.
2408
+ *
2409
+ * Only `mutateAsync` promises reject with this; plain `mutate()` calls have no
2410
+ * promise and so produce no (potentially unhandled) rejection.
2411
+ */
2412
+ class MutationCancelledError extends Error {
2413
+ type;
2414
+ constructor(type, message) {
2415
+ super(message);
2416
+ this.type = type;
2417
+ this.name = 'MutationCancelledError';
2418
+ }
2419
+ }
2403
2420
  const MUTATION_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:mutation-resource-options', { factory: () => ({}) });
2404
2421
  /**
2405
2422
  * Layer 2 (mutation): default options for every `mutationResource`, inheriting + overriding the
@@ -2413,55 +2430,6 @@ function injectMutationResourceOptions(injector) {
2413
2430
  ? injector.get(MUTATION_RESOURCE_OPTIONS)
2414
2431
  : inject(MUTATION_RESOURCE_OPTIONS);
2415
2432
  }
2416
- /**
2417
- * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
2418
- * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
2419
- * It does *not* cache responses and does not provide a `value` signal. Instead, it focuses on
2420
- * managing the mutation lifecycle (pending, error, success) and provides callbacks for handling
2421
- * these states.
2422
- *
2423
- * @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.
2424
- * @param options Configuration options for the mutation resource. This includes callbacks
2425
- * for `onMutate`, `onError`, `onSuccess`, and `onSettled`.
2426
- * @typeParam TResult - The type of the expected result from the mutation.
2427
- * @typeParam TRaw - The raw response type from the HTTP request (defaults to TResult).
2428
- * @typeParam TMutation - The type of the mutation value (the request body).
2429
- * @typeParam TICTX - The type of the initial context value passed to `onMutate`.
2430
- * @typeParam TCTX - The type of the context value returned by `onMutate`.
2431
- * @typeParam TMethod - The HTTP method to be used for the mutation (defaults to `HttpResourceRequest['method']`).
2432
- * @returns A `MutationResourceRef` instance, which provides methods for triggering the mutation
2433
- * and observing its status.
2434
- *
2435
- * @example
2436
- * ```ts
2437
- * // Basic PATCH mutation
2438
- * const updateUser = mutationResource<User, User, Partial<User>>(
2439
- * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2440
- * {
2441
- * onSuccess: (saved) => toast.success(`Updated ${saved.name}`),
2442
- * onError: (err) => toast.error(err),
2443
- * },
2444
- * );
2445
- *
2446
- * updateUser.mutate({ name: 'Alice' });
2447
- * ```
2448
- *
2449
- * @example
2450
- * ```ts
2451
- * // Optimistic update with rollback via the `ctx` returned from `onMutate`
2452
- * const updateUser = mutationResource<User, User, Partial<User>, { prev: User | null }>(
2453
- * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2454
- * {
2455
- * onMutate: (patch) => {
2456
- * const prev = current();
2457
- * current.update((u) => (u ? { ...u, ...patch } : u));
2458
- * return { prev };
2459
- * },
2460
- * onError: (_err, { prev }) => current.set(prev),
2461
- * },
2462
- * );
2463
- * ```
2464
- */
2465
2433
  function mutationResource(request, options0 = {}) {
2466
2434
  // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
2467
2435
  const globalOpts = injectResourceOptions(options0.injector);
@@ -2478,11 +2446,6 @@ function mutationResource(request, options0 = {}) {
2478
2446
  const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2479
2447
  const cache = invalidates ? injectQueryCache(options.injector) : undefined;
2480
2448
  const requestEqual = equalRequest ?? createEqualRequest(equal);
2481
- // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
2482
- // even with an identical body". By default we dedup an identical value/request while one is in
2483
- // flight (double-click protection); when this is set, both the `next` and `req` dedup are bypassed
2484
- // so a repeat click isn't silently swallowed mid-flight. (Otherwise it'd be dropped until `next`
2485
- // resets to NULL on settle — the "every other click" symptom.)
2486
2449
  const triggerOnSame = options.triggerOnSameRequest ?? false;
2487
2450
  const eq = equal ?? Object.is;
2488
2451
  const next = signal(NULL_VALUE, ...(ngDevMode ? [{ debugName: "next", equal: (a, b) => {
@@ -2504,25 +2467,27 @@ function mutationResource(request, options0 = {}) {
2504
2467
  return eq(a, b);
2505
2468
  },
2506
2469
  }]));
2507
- const queue = signal([], ...(ngDevMode ? [{ debugName: "queue" }] : []));
2508
- let ctx = undefined;
2509
- const queueRef = effect(() => {
2510
- const nextInQueue = queue().at(0);
2511
- if (nextInQueue === undefined || next() !== NULL_VALUE)
2512
- return;
2513
- queue.update((q) => q.slice(1));
2514
- const [value, ictx] = nextInQueue;
2515
- try {
2516
- ctx = onMutate?.(value, ictx);
2517
- next.set(value);
2518
- }
2519
- catch (mutationErr) {
2520
- ctx = undefined;
2521
- next.set(NULL_VALUE);
2522
- if (isDevMode())
2523
- console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2524
- }
2525
- }, ...(ngDevMode ? [{ debugName: "queueRef", injector: options.injector }] : [{ injector: options.injector }]));
2470
+ const queueEnabled = !!options.queue;
2471
+ const queueKeyFn = typeof options.queue === 'object' ? options.queue.key : undefined;
2472
+ const queue = linkedSignal(...(ngDevMode ? [{ debugName: "queue", source: () => queueKeyFn?.(),
2473
+ computation: (_key, prev) => {
2474
+ // On a queue key change the previous pending entries are dropped — reject any
2475
+ // mutateAsync promises waiting on them so awaiters don't hang.
2476
+ if (prev)
2477
+ for (const [, , deferred] of untracked(prev.value))
2478
+ deferred?.reject(new MutationCancelledError('queue-key-changed', 'mutation dropped: queue key changed before it ran'));
2479
+ return signal([]);
2480
+ } }] : [{
2481
+ source: () => queueKeyFn?.(),
2482
+ computation: (_key, prev) => {
2483
+ // On a queue key change the previous pending entries are dropped — reject any
2484
+ // mutateAsync promises waiting on them so awaiters don't hang.
2485
+ if (prev)
2486
+ for (const [, , deferred] of untracked(prev.value))
2487
+ deferred?.reject(new MutationCancelledError('queue-key-changed', 'mutation dropped: queue key changed before it ran'));
2488
+ return signal([]);
2489
+ },
2490
+ }]));
2526
2491
  const req = computed(() => {
2527
2492
  const nr = next();
2528
2493
  if (nr === NULL_VALUE)
@@ -2547,6 +2512,57 @@ function mutationResource(request, options0 = {}) {
2547
2512
  return requestEqual(a, b);
2548
2513
  },
2549
2514
  }]));
2515
+ let ctx = undefined;
2516
+ let currentDeferred;
2517
+ const begin = (value, ictx, deferred) => {
2518
+ let nextCtx;
2519
+ try {
2520
+ nextCtx = onMutate?.(value, ictx);
2521
+ }
2522
+ catch (mutationErr) {
2523
+ // match legacy mutate(): the throw aborts the mutation and resets state
2524
+ ctx = undefined;
2525
+ next.set(NULL_VALUE);
2526
+ deferred?.reject(mutationErr);
2527
+ if (isDevMode())
2528
+ console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2529
+ return;
2530
+ }
2531
+ ctx = nextCtx;
2532
+ currentDeferred = deferred;
2533
+ next.set(value);
2534
+ if (deferred && untracked(req) === undefined) {
2535
+ ctx = undefined;
2536
+ currentDeferred = undefined;
2537
+ next.set(NULL_VALUE);
2538
+ deferred.reject(new MutationCancelledError('no-request', 'mutation not sent: request() returned undefined'));
2539
+ }
2540
+ };
2541
+ const supersedeInFlight = () => {
2542
+ if (untracked(next) === NULL_VALUE)
2543
+ return;
2544
+ if (isDevMode())
2545
+ console.warn('[@mmstack/resource]: mutate() called while another mutation was in flight — the previous mutation was superseded (latest-wins) and its onSettled was invoked. Use `queue: true` for sequential mutations.');
2546
+ try {
2547
+ onSettled?.(ctx);
2548
+ }
2549
+ catch (settleErr) {
2550
+ if (isDevMode())
2551
+ console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2552
+ }
2553
+ currentDeferred?.reject(new MutationCancelledError('superseded', 'mutation superseded by a newer mutation (latest-wins)'));
2554
+ currentDeferred = undefined;
2555
+ ctx = undefined;
2556
+ };
2557
+ const queueRef = effect(() => {
2558
+ const q = queue(); // subscribe to swaps (key change / clearQueue)
2559
+ const nextInQueue = q().at(0); // subscribe to contents
2560
+ if (nextInQueue === undefined || next() !== NULL_VALUE)
2561
+ return;
2562
+ q.update((arr) => arr.slice(1));
2563
+ const [value, ictx, deferred] = nextInQueue;
2564
+ begin(value, ictx, deferred);
2565
+ }, ...(ngDevMode ? [{ debugName: "queueRef", injector: options.injector }] : [{ injector: options.injector }]));
2550
2566
  const lastValue = linkedSignal(...(ngDevMode ? [{ debugName: "lastValue", source: next,
2551
2567
  computation: (next, prev) => {
2552
2568
  if (next === NULL_VALUE && !!prev)
@@ -2616,8 +2632,12 @@ function mutationResource(request, options0 = {}) {
2616
2632
  return NULL_VALUE;
2617
2633
  }), filter((v) => v !== NULL_VALUE), takeUntilDestroyed(destroyRef))
2618
2634
  .subscribe((result) => {
2619
- if (result.status === 'error')
2635
+ const deferred = currentDeferred;
2636
+ currentDeferred = undefined;
2637
+ if (result.status === 'error') {
2620
2638
  onError?.(result.error, ctx);
2639
+ deferred?.reject(result.error);
2640
+ }
2621
2641
  else {
2622
2642
  onSuccess?.(result.value, ctx);
2623
2643
  if (cache && invalidates) {
@@ -2625,61 +2645,61 @@ function mutationResource(request, options0 = {}) {
2625
2645
  const prefixes = typeof invalidates === 'function'
2626
2646
  ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2627
2647
  : invalidates;
2628
- // auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
2629
- // the url with any params/subpaths and every varyHeaders variant
2630
2648
  for (const prefix of prefixes)
2631
2649
  cache.invalidatePrefix(`GET:${prefix}`);
2632
2650
  }
2651
+ deferred?.resolve(result.value);
2633
2652
  }
2634
2653
  onSettled?.(ctx);
2635
2654
  ctx = undefined;
2636
2655
  next.set(NULL_VALUE);
2637
2656
  });
2638
- const shouldQueue = options.queue ?? false;
2639
2657
  const ref = {
2640
2658
  ...resource,
2641
2659
  destroy: () => {
2642
2660
  // queue first — a late queue flush must not poke an already-destroyed resource
2643
2661
  queueRef.destroy();
2644
2662
  statusSub.unsubscribe();
2663
+ // reject any outstanding mutateAsync promises so awaiters don't hang
2664
+ const cancelled = new MutationCancelledError('destroyed', 'mutation abandoned: resource destroyed');
2665
+ currentDeferred?.reject(cancelled);
2666
+ currentDeferred = undefined;
2667
+ for (const [, , deferred] of untracked(queue)())
2668
+ deferred?.reject(cancelled);
2645
2669
  resource.destroy();
2646
2670
  },
2647
2671
  mutate: (value, ictx) => {
2648
- if (shouldQueue) {
2649
- return queue.update((q) => [...q, [value, ictx]]);
2672
+ if (queueEnabled) {
2673
+ queue().update((arr) => [...arr, [value, ictx, undefined]]);
2674
+ return;
2675
+ }
2676
+ supersedeInFlight();
2677
+ begin(value, ictx, undefined);
2678
+ },
2679
+ mutateAsync: (value, ictx) => {
2680
+ const deferred = Promise.withResolvers();
2681
+ if (queueEnabled) {
2682
+ queue().update((arr) => [...arr, [value, ictx, deferred]]);
2650
2683
  }
2651
2684
  else {
2652
- // latest-wins: a mutation already in flight gets superseded (its request is
2653
- // aborted by the request change), so its onSuccess/onError will never fire —
2654
- // settle its context NOW so optimistic state can be rolled back/cleaned up
2655
- if (untracked(next) !== NULL_VALUE) {
2656
- if (isDevMode())
2657
- console.warn('[@mmstack/resource]: mutate() called while another mutation was in flight — the previous mutation was superseded (latest-wins) and its onSettled was invoked. Use `queue: true` for sequential mutations.');
2658
- try {
2659
- onSettled?.(ctx);
2660
- }
2661
- catch (settleErr) {
2662
- if (isDevMode())
2663
- console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2664
- }
2665
- ctx = undefined;
2666
- }
2667
- try {
2668
- ctx = onMutate?.(value, ictx);
2669
- next.set(value);
2670
- }
2671
- catch (mutationErr) {
2672
- ctx = undefined;
2673
- next.set(NULL_VALUE);
2674
- if (isDevMode())
2675
- console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2676
- }
2685
+ supersedeInFlight();
2686
+ begin(value, ictx, deferred);
2677
2687
  }
2688
+ return deferred.promise;
2678
2689
  },
2679
2690
  current: computed(() => {
2680
2691
  const nv = next();
2681
2692
  return nv === NULL_VALUE ? null : nv;
2682
2693
  }),
2694
+ clearQueue: () => {
2695
+ if (!queueEnabled)
2696
+ return;
2697
+ const dropped = untracked(queue)();
2698
+ queue.set(signal([]));
2699
+ // reject mutateAsync promises whose entries we just dropped
2700
+ for (const [, , deferred] of dropped)
2701
+ deferred?.reject(new MutationCancelledError('queue-cleared', 'mutation dropped: queue cleared before it ran'));
2702
+ },
2683
2703
  // redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
2684
2704
  disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
2685
2705
  };
@@ -2691,5 +2711,5 @@ function mutationResource(request, options0 = {}) {
2691
2711
  * Generated bundle index. Do not edit.
2692
2712
  */
2693
2713
 
2694
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2714
+ export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2695
2715
  //# sourceMappingURL=mmstack-resource.mjs.map