@mmstack/resource 22.1.5 → 22.1.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
 
@@ -1853,10 +1853,10 @@ function retryOnError(res, opt, onError) {
1853
1853
  class ResourceSensors {
1854
1854
  networkStatus = sensor('networkStatus');
1855
1855
  pageVisibility = sensor('pageVisibility');
1856
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1857
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1856
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1857
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1858
1858
  }
1859
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: ResourceSensors, decorators: [{
1859
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: ResourceSensors, decorators: [{
1860
1860
  type: Injectable,
1861
1861
  args: [{
1862
1862
  providedIn: 'root',
@@ -2377,6 +2377,23 @@ function manualQueryResource(request, options) {
2377
2377
  }
2378
2378
 
2379
2379
  const NULL_VALUE = Symbol('@mmstack/resource:null');
2380
+ /**
2381
+ * Rejection reason for a {@link MutationResourceRef.mutateAsync} promise whose
2382
+ * mutation never completed. The {@link MutationCancelledError.type} discriminant
2383
+ * carries the cause ({@link MutationCancellationReason}); the message is a
2384
+ * human-readable elaboration of it.
2385
+ *
2386
+ * Only `mutateAsync` promises reject with this; plain `mutate()` calls have no
2387
+ * promise and so produce no (potentially unhandled) rejection.
2388
+ */
2389
+ class MutationCancelledError extends Error {
2390
+ type;
2391
+ constructor(type, message) {
2392
+ super(message);
2393
+ this.type = type;
2394
+ this.name = 'MutationCancelledError';
2395
+ }
2396
+ }
2380
2397
  const MUTATION_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:mutation-resource-options', { factory: () => ({}) });
2381
2398
  /**
2382
2399
  * Layer 2 (mutation): default options for every `mutationResource`, inheriting + overriding the
@@ -2390,55 +2407,6 @@ function injectMutationResourceOptions(injector) {
2390
2407
  ? injector.get(MUTATION_RESOURCE_OPTIONS)
2391
2408
  : inject(MUTATION_RESOURCE_OPTIONS);
2392
2409
  }
2393
- /**
2394
- * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
2395
- * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
2396
- * It does *not* cache responses and does not provide a `value` signal. Instead, it focuses on
2397
- * managing the mutation lifecycle (pending, error, success) and provides callbacks for handling
2398
- * these states.
2399
- *
2400
- * @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.
2401
- * @param options Configuration options for the mutation resource. This includes callbacks
2402
- * for `onMutate`, `onError`, `onSuccess`, and `onSettled`.
2403
- * @typeParam TResult - The type of the expected result from the mutation.
2404
- * @typeParam TRaw - The raw response type from the HTTP request (defaults to TResult).
2405
- * @typeParam TMutation - The type of the mutation value (the request body).
2406
- * @typeParam TICTX - The type of the initial context value passed to `onMutate`.
2407
- * @typeParam TCTX - The type of the context value returned by `onMutate`.
2408
- * @typeParam TMethod - The HTTP method to be used for the mutation (defaults to `HttpResourceRequest['method']`).
2409
- * @returns A `MutationResourceRef` instance, which provides methods for triggering the mutation
2410
- * and observing its status.
2411
- *
2412
- * @example
2413
- * ```ts
2414
- * // Basic PATCH mutation
2415
- * const updateUser = mutationResource<User, User, Partial<User>>(
2416
- * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2417
- * {
2418
- * onSuccess: (saved) => toast.success(`Updated ${saved.name}`),
2419
- * onError: (err) => toast.error(err),
2420
- * },
2421
- * );
2422
- *
2423
- * updateUser.mutate({ name: 'Alice' });
2424
- * ```
2425
- *
2426
- * @example
2427
- * ```ts
2428
- * // Optimistic update with rollback via the `ctx` returned from `onMutate`
2429
- * const updateUser = mutationResource<User, User, Partial<User>, { prev: User | null }>(
2430
- * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
2431
- * {
2432
- * onMutate: (patch) => {
2433
- * const prev = current();
2434
- * current.update((u) => (u ? { ...u, ...patch } : u));
2435
- * return { prev };
2436
- * },
2437
- * onError: (_err, { prev }) => current.set(prev),
2438
- * },
2439
- * );
2440
- * ```
2441
- */
2442
2410
  function mutationResource(request, options0 = {}) {
2443
2411
  // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
2444
2412
  const globalOpts = injectResourceOptions(options0.injector);
@@ -2455,11 +2423,6 @@ function mutationResource(request, options0 = {}) {
2455
2423
  const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
2456
2424
  const cache = invalidates ? injectQueryCache(options.injector) : undefined;
2457
2425
  const requestEqual = equalRequest ?? createEqualRequest(equal);
2458
- // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
2459
- // even with an identical body". By default we dedup an identical value/request while one is in
2460
- // flight (double-click protection); when this is set, both the `next` and `req` dedup are bypassed
2461
- // so a repeat click isn't silently swallowed mid-flight. (Otherwise it'd be dropped until `next`
2462
- // resets to NULL on settle — the "every other click" symptom.)
2463
2426
  const triggerOnSame = options.triggerOnSameRequest ?? false;
2464
2427
  const eq = equal ?? Object.is;
2465
2428
  const next = signal(NULL_VALUE, { ...(ngDevMode ? { debugName: "next" } : /* istanbul ignore next */ {}), equal: (a, b) => {
@@ -2474,26 +2437,14 @@ function mutationResource(request, options0 = {}) {
2474
2437
  const queueEnabled = !!options.queue;
2475
2438
  const queueKeyFn = typeof options.queue === 'object' ? options.queue.key : undefined;
2476
2439
  const queue = linkedSignal({ ...(ngDevMode ? { debugName: "queue" } : /* istanbul ignore next */ {}), source: () => queueKeyFn?.(),
2477
- computation: () => signal([]) });
2478
- let ctx = undefined;
2479
- const queueRef = effect(() => {
2480
- const q = queue(); // subscribe to swaps (key change / clearQueue)
2481
- const nextInQueue = q().at(0); // subscribe to contents
2482
- if (nextInQueue === undefined || next() !== NULL_VALUE)
2483
- return;
2484
- q.update((arr) => arr.slice(1));
2485
- const [value, ictx] = nextInQueue;
2486
- try {
2487
- ctx = onMutate?.(value, ictx);
2488
- next.set(value);
2489
- }
2490
- catch (mutationErr) {
2491
- ctx = undefined;
2492
- next.set(NULL_VALUE);
2493
- if (isDevMode())
2494
- console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2495
- }
2496
- }, { ...(ngDevMode ? { debugName: "queueRef" } : /* istanbul ignore next */ {}), injector: options.injector });
2440
+ computation: (_key, prev) => {
2441
+ // On a queue key change the previous pending entries are dropped — reject any
2442
+ // mutateAsync promises waiting on them so awaiters don't hang.
2443
+ if (prev)
2444
+ for (const [, , deferred] of untracked(prev.value))
2445
+ deferred?.reject(new MutationCancelledError('queue-key-changed', 'mutation dropped: queue key changed before it ran'));
2446
+ return signal([]);
2447
+ } });
2497
2448
  const req = computed(() => {
2498
2449
  const nr = next();
2499
2450
  if (nr === NULL_VALUE)
@@ -2508,6 +2459,57 @@ function mutationResource(request, options0 = {}) {
2508
2459
  return false;
2509
2460
  return requestEqual(a, b);
2510
2461
  } });
2462
+ let ctx = undefined;
2463
+ let currentDeferred;
2464
+ const begin = (value, ictx, deferred) => {
2465
+ let nextCtx;
2466
+ try {
2467
+ nextCtx = onMutate?.(value, ictx);
2468
+ }
2469
+ catch (mutationErr) {
2470
+ // match legacy mutate(): the throw aborts the mutation and resets state
2471
+ ctx = undefined;
2472
+ next.set(NULL_VALUE);
2473
+ deferred?.reject(mutationErr);
2474
+ if (isDevMode())
2475
+ console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2476
+ return;
2477
+ }
2478
+ ctx = nextCtx;
2479
+ currentDeferred = deferred;
2480
+ next.set(value);
2481
+ if (deferred && untracked(req) === undefined) {
2482
+ ctx = undefined;
2483
+ currentDeferred = undefined;
2484
+ next.set(NULL_VALUE);
2485
+ deferred.reject(new MutationCancelledError('no-request', 'mutation not sent: request() returned undefined'));
2486
+ }
2487
+ };
2488
+ const supersedeInFlight = () => {
2489
+ if (untracked(next) === NULL_VALUE)
2490
+ return;
2491
+ if (isDevMode())
2492
+ 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.');
2493
+ try {
2494
+ onSettled?.(ctx);
2495
+ }
2496
+ catch (settleErr) {
2497
+ if (isDevMode())
2498
+ console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2499
+ }
2500
+ currentDeferred?.reject(new MutationCancelledError('superseded', 'mutation superseded by a newer mutation (latest-wins)'));
2501
+ currentDeferred = undefined;
2502
+ ctx = undefined;
2503
+ };
2504
+ const queueRef = effect(() => {
2505
+ const q = queue(); // subscribe to swaps (key change / clearQueue)
2506
+ const nextInQueue = q().at(0); // subscribe to contents
2507
+ if (nextInQueue === undefined || next() !== NULL_VALUE)
2508
+ return;
2509
+ q.update((arr) => arr.slice(1));
2510
+ const [value, ictx, deferred] = nextInQueue;
2511
+ begin(value, ictx, deferred);
2512
+ }, { ...(ngDevMode ? { debugName: "queueRef" } : /* istanbul ignore next */ {}), injector: options.injector });
2511
2513
  const lastValue = linkedSignal({ ...(ngDevMode ? { debugName: "lastValue" } : /* istanbul ignore next */ {}), source: next,
2512
2514
  computation: (next, prev) => {
2513
2515
  if (next === NULL_VALUE && !!prev)
@@ -2562,8 +2564,12 @@ function mutationResource(request, options0 = {}) {
2562
2564
  return NULL_VALUE;
2563
2565
  }), filter((v) => v !== NULL_VALUE), takeUntilDestroyed(destroyRef))
2564
2566
  .subscribe((result) => {
2565
- if (result.status === 'error')
2567
+ const deferred = currentDeferred;
2568
+ currentDeferred = undefined;
2569
+ if (result.status === 'error') {
2566
2570
  onError?.(result.error, ctx);
2571
+ deferred?.reject(result.error);
2572
+ }
2567
2573
  else {
2568
2574
  onSuccess?.(result.value, ctx);
2569
2575
  if (cache && invalidates) {
@@ -2571,11 +2577,10 @@ function mutationResource(request, options0 = {}) {
2571
2577
  const prefixes = typeof invalidates === 'function'
2572
2578
  ? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
2573
2579
  : invalidates;
2574
- // auto-keys are `${method}:${url}:...` — a `GET:`-prefixed url prefix hits
2575
- // the url with any params/subpaths and every varyHeaders variant
2576
2580
  for (const prefix of prefixes)
2577
2581
  cache.invalidatePrefix(`GET:${prefix}`);
2578
2582
  }
2583
+ deferred?.resolve(result.value);
2579
2584
  }
2580
2585
  onSettled?.(ctx);
2581
2586
  ctx = undefined;
@@ -2587,39 +2592,32 @@ function mutationResource(request, options0 = {}) {
2587
2592
  // queue first — a late queue flush must not poke an already-destroyed resource
2588
2593
  queueRef.destroy();
2589
2594
  statusSub.unsubscribe();
2595
+ // reject any outstanding mutateAsync promises so awaiters don't hang
2596
+ const cancelled = new MutationCancelledError('destroyed', 'mutation abandoned: resource destroyed');
2597
+ currentDeferred?.reject(cancelled);
2598
+ currentDeferred = undefined;
2599
+ for (const [, , deferred] of untracked(queue)())
2600
+ deferred?.reject(cancelled);
2590
2601
  resource.destroy();
2591
2602
  },
2592
2603
  mutate: (value, ictx) => {
2593
2604
  if (queueEnabled) {
2594
- return queue().update((arr) => [...arr, [value, ictx]]);
2605
+ queue().update((arr) => [...arr, [value, ictx, undefined]]);
2606
+ return;
2607
+ }
2608
+ supersedeInFlight();
2609
+ begin(value, ictx, undefined);
2610
+ },
2611
+ mutateAsync: (value, ictx) => {
2612
+ const deferred = Promise.withResolvers();
2613
+ if (queueEnabled) {
2614
+ queue().update((arr) => [...arr, [value, ictx, deferred]]);
2595
2615
  }
2596
2616
  else {
2597
- // latest-wins: a mutation already in flight gets superseded (its request is
2598
- // aborted by the request change), so its onSuccess/onError will never fire —
2599
- // settle its context NOW so optimistic state can be rolled back/cleaned up
2600
- if (untracked(next) !== NULL_VALUE) {
2601
- if (isDevMode())
2602
- 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.');
2603
- try {
2604
- onSettled?.(ctx);
2605
- }
2606
- catch (settleErr) {
2607
- if (isDevMode())
2608
- console.error('[@mmstack/resource]: error thrown in onSettled hook for a superseded mutation', settleErr);
2609
- }
2610
- ctx = undefined;
2611
- }
2612
- try {
2613
- ctx = onMutate?.(value, ictx);
2614
- next.set(value);
2615
- }
2616
- catch (mutationErr) {
2617
- ctx = undefined;
2618
- next.set(NULL_VALUE);
2619
- if (isDevMode())
2620
- console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
2621
- }
2617
+ supersedeInFlight();
2618
+ begin(value, ictx, deferred);
2622
2619
  }
2620
+ return deferred.promise;
2623
2621
  },
2624
2622
  current: computed(() => {
2625
2623
  const nv = next();
@@ -2628,7 +2626,11 @@ function mutationResource(request, options0 = {}) {
2628
2626
  clearQueue: () => {
2629
2627
  if (!queueEnabled)
2630
2628
  return;
2629
+ const dropped = untracked(queue)();
2631
2630
  queue.set(signal([]));
2631
+ // reject mutateAsync promises whose entries we just dropped
2632
+ for (const [, , deferred] of dropped)
2633
+ deferred?.reject(new MutationCancelledError('queue-cleared', 'mutation dropped: queue cleared before it ran'));
2632
2634
  },
2633
2635
  // redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
2634
2636
  disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
@@ -2641,5 +2643,5 @@ function mutationResource(request, options0 = {}) {
2641
2643
  * Generated bundle index. Do not edit.
2642
2644
  */
2643
2645
 
2644
- export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2646
+ export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
2645
2647
  //# sourceMappingURL=mmstack-resource.mjs.map