@mmstack/resource 20.5.2 → 20.6.1
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 +4 -4
- package/fesm2022/mmstack-resource.mjs +221 -129
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/index.d.ts +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -123,15 +123,15 @@ All three return a signal-typed ref — `value()`, `status()`, `error()`, `heade
|
|
|
123
123
|
|
|
124
124
|
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.
|
|
125
125
|
|
|
126
|
-
**Default key**:
|
|
126
|
+
**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`.**
|
|
127
127
|
|
|
128
|
-
If two requests should _not_ share a cache entry but the default key would collide (e.g. different `Authorization` headers
|
|
128
|
+
If two requests should _not_ share a cache entry but the default key would collide (e.g. different `Authorization` headers), pass a custom hash:
|
|
129
129
|
|
|
130
130
|
```typescript
|
|
131
131
|
queryResource<Post>(() => ({ url, headers }), {
|
|
132
132
|
cache: {
|
|
133
133
|
hash: (req) =>
|
|
134
|
-
`${req
|
|
134
|
+
`${hashRequest(req)}:${(req.headers as HttpHeaders | undefined)?.get('Authorization') ?? ''}`,
|
|
135
135
|
},
|
|
136
136
|
});
|
|
137
137
|
```
|
|
@@ -321,7 +321,7 @@ provideQueryCache({
|
|
|
321
321
|
| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
|
|
322
322
|
| `staleTime` | from `provideQueryCache` | Per-resource override. |
|
|
323
323
|
| `ttl` | from `provideQueryCache` | Per-resource override. |
|
|
324
|
-
| `hash` | `
|
|
324
|
+
| `hash` | `hashRequest` | Custom cache key function. See [cache + cache keys](#cache--cache-keys). |
|
|
325
325
|
| `persist` | `false` | Mirror this resource's responses to IndexedDB (only effective if the cache itself was created with `persist: true`). |
|
|
326
326
|
| `ignoreCacheControl` | `false` | Ignore HTTP `Cache-Control` directives and use only `staleTime`/`ttl`. |
|
|
327
327
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, Injectable, DestroyRef } from '@angular/core';
|
|
3
3
|
import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
|
|
4
|
-
import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext,
|
|
4
|
+
import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
|
|
5
5
|
import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
|
|
6
6
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
7
7
|
|
|
@@ -550,6 +550,173 @@ function injectQueryCache(injector) {
|
|
|
550
550
|
return cache;
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Returns `true` for any object-like value whose own enumerable keys should
|
|
555
|
+
* be sorted for stable hashing. Excludes arrays (positional), `Date`
|
|
556
|
+
* (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
|
|
557
|
+
* (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
|
|
558
|
+
* should be branched on before reaching `hash()`, typically by `hashRequest`).
|
|
559
|
+
*
|
|
560
|
+
* Plain objects, class instances, and `Object.create(null)` all qualify.
|
|
561
|
+
*/
|
|
562
|
+
function isHashableObject(value) {
|
|
563
|
+
if (value === null || typeof value !== 'object')
|
|
564
|
+
return false;
|
|
565
|
+
if (Array.isArray(value))
|
|
566
|
+
return false;
|
|
567
|
+
if (value instanceof Date)
|
|
568
|
+
return false;
|
|
569
|
+
if (value instanceof Map)
|
|
570
|
+
return false;
|
|
571
|
+
if (value instanceof Set)
|
|
572
|
+
return false;
|
|
573
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob)
|
|
574
|
+
return false;
|
|
575
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData)
|
|
576
|
+
return false;
|
|
577
|
+
if (typeof URLSearchParams !== 'undefined' &&
|
|
578
|
+
value instanceof URLSearchParams)
|
|
579
|
+
return false;
|
|
580
|
+
if (value instanceof ArrayBuffer)
|
|
581
|
+
return false;
|
|
582
|
+
if (ArrayBuffer.isView(value))
|
|
583
|
+
return false;
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
function sortKeys(val) {
|
|
587
|
+
return Object.keys(val)
|
|
588
|
+
.toSorted()
|
|
589
|
+
.reduce((result, key) => {
|
|
590
|
+
result[key] = val[key];
|
|
591
|
+
return result;
|
|
592
|
+
}, {});
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Internal helper to generate a stable JSON string from an array.
|
|
596
|
+
* - Object-like values (plain, class instances, null-proto) get their own
|
|
597
|
+
* enumerable keys sorted alphabetically.
|
|
598
|
+
* - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
|
|
599
|
+
* - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
|
|
600
|
+
* - Arrays preserve order. `Date` serializes via `toJSON`.
|
|
601
|
+
*
|
|
602
|
+
* @internal
|
|
603
|
+
*/
|
|
604
|
+
function hashKey(queryKey) {
|
|
605
|
+
return JSON.stringify(queryKey, (_, val) => {
|
|
606
|
+
if (val instanceof Map) {
|
|
607
|
+
// Schwartzian: compute each entry's sort key (recursive hash of the
|
|
608
|
+
// Map key) once, then sort by the cheap string compare.
|
|
609
|
+
const entries = [...val.entries()]
|
|
610
|
+
.map((e) => [hash(e[0]), e])
|
|
611
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
612
|
+
.map(([, e]) => e);
|
|
613
|
+
return { __map__: entries };
|
|
614
|
+
}
|
|
615
|
+
if (val instanceof Set) {
|
|
616
|
+
const values = [...val]
|
|
617
|
+
.map((v) => [hash(v), v])
|
|
618
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
619
|
+
.map(([, v]) => v);
|
|
620
|
+
return { __set__: values };
|
|
621
|
+
}
|
|
622
|
+
if (isHashableObject(val))
|
|
623
|
+
return sortKeys(val);
|
|
624
|
+
return val;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Generates a stable, unique string hash from one or more arguments.
|
|
629
|
+
* Useful for creating cache keys or identifiers where object key order shouldn't matter.
|
|
630
|
+
*
|
|
631
|
+
* How it works:
|
|
632
|
+
* - Object-like values (plain objects, class instances, `Object.create(null)`) have
|
|
633
|
+
* their own enumerable keys sorted alphabetically before hashing. This ensures
|
|
634
|
+
* `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
|
|
635
|
+
* - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
|
|
636
|
+
* - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
|
|
637
|
+
*
|
|
638
|
+
* @param {...unknown} args Values to include in the hash.
|
|
639
|
+
* @returns A stable string hash representing the input arguments.
|
|
640
|
+
* @example
|
|
641
|
+
* hash('posts', 10);
|
|
642
|
+
* // => '["posts",10]'
|
|
643
|
+
*
|
|
644
|
+
* hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
|
|
645
|
+
*
|
|
646
|
+
* hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
|
|
647
|
+
*
|
|
648
|
+
* // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
|
|
649
|
+
* // hash('a', undefined, function() {}) => '["a",null,null]'
|
|
650
|
+
*/
|
|
651
|
+
function hash(...args) {
|
|
652
|
+
return hashKey(args);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function normalizeParams(params) {
|
|
656
|
+
const p = params instanceof HttpParams
|
|
657
|
+
? params
|
|
658
|
+
: new HttpParams({ fromObject: params });
|
|
659
|
+
return p
|
|
660
|
+
.keys()
|
|
661
|
+
.toSorted()
|
|
662
|
+
.map((key) => {
|
|
663
|
+
const encodedKey = encodeURIComponent(key);
|
|
664
|
+
return (p.getAll(key) ?? [])
|
|
665
|
+
.map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
|
|
666
|
+
.join('&');
|
|
667
|
+
})
|
|
668
|
+
.join('&');
|
|
669
|
+
}
|
|
670
|
+
function hashBody(body) {
|
|
671
|
+
// File extends Blob — must check File first
|
|
672
|
+
if (typeof File !== 'undefined' && body instanceof File) {
|
|
673
|
+
return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
|
|
674
|
+
}
|
|
675
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
676
|
+
return `Blob:${body.type}:${body.size}`;
|
|
677
|
+
}
|
|
678
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
679
|
+
const entries = [];
|
|
680
|
+
body.forEach((value, key) => {
|
|
681
|
+
entries.push([key, hashBody(value)]);
|
|
682
|
+
});
|
|
683
|
+
entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
|
|
684
|
+
return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
|
|
685
|
+
}
|
|
686
|
+
if (typeof URLSearchParams !== 'undefined' &&
|
|
687
|
+
body instanceof URLSearchParams) {
|
|
688
|
+
const sp = new URLSearchParams(body);
|
|
689
|
+
sp.sort();
|
|
690
|
+
return `URLSearchParams:${sp.toString()}`;
|
|
691
|
+
}
|
|
692
|
+
if (body instanceof ArrayBuffer) {
|
|
693
|
+
return `ArrayBuffer:${body.byteLength}`;
|
|
694
|
+
}
|
|
695
|
+
if (ArrayBuffer.isView(body)) {
|
|
696
|
+
return `${body.constructor.name}:${body.byteLength}`;
|
|
697
|
+
}
|
|
698
|
+
return hash(body);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Builds a stable cache/dedupe key from an HTTP request shape (accepts both
|
|
702
|
+
* `HttpRequest` and `HttpResourceRequest`).
|
|
703
|
+
*
|
|
704
|
+
* Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
|
|
705
|
+
* - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
|
|
706
|
+
* - Query params are sorted alphabetically and URL-encoded for stability.
|
|
707
|
+
* - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
|
|
708
|
+
* and typed arrays explicitly; everything else flows through key-sorted
|
|
709
|
+
* `JSON.stringify` via `hash()`.
|
|
710
|
+
*/
|
|
711
|
+
function hashRequest(req) {
|
|
712
|
+
const method = req.method ?? 'GET';
|
|
713
|
+
const responseType = req.responseType ?? 'json';
|
|
714
|
+
const base = `${method}:${req.url}:${responseType}`;
|
|
715
|
+
const params = req.params ? `:${normalizeParams(req.params)}` : '';
|
|
716
|
+
const body = req.body != null ? `:${hashBody(req.body)}` : '';
|
|
717
|
+
return base + params + body;
|
|
718
|
+
}
|
|
719
|
+
|
|
553
720
|
const CACHE_CONTEXT = new HttpContextToken(() => ({
|
|
554
721
|
cache: false,
|
|
555
722
|
}));
|
|
@@ -693,7 +860,7 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
693
860
|
const opt = getCacheContext(req.context);
|
|
694
861
|
if (!opt.cache)
|
|
695
862
|
return next(req);
|
|
696
|
-
const key = opt.key ?? req
|
|
863
|
+
const key = opt.key ?? hashRequest(req);
|
|
697
864
|
const entry = cache.getUntracked(key); // null if expired or not found
|
|
698
865
|
// If the entry is not stale, return it
|
|
699
866
|
if (entry && !entry.isStale)
|
|
@@ -976,6 +1143,9 @@ function noDedupe(ctx = new HttpContext()) {
|
|
|
976
1143
|
*
|
|
977
1144
|
* @param allowed - An array of HTTP methods for which deduplication should be enabled.
|
|
978
1145
|
* Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
|
|
1146
|
+
* @param keyFn - Optional function to compute the dedupe key from a request.
|
|
1147
|
+
* Defaults to `hashRequest`, which includes method, URL,
|
|
1148
|
+
* response type, params, and body.
|
|
979
1149
|
*
|
|
980
1150
|
* @returns An `HttpInterceptorFn` that implements the request deduplication logic.
|
|
981
1151
|
*
|
|
@@ -999,97 +1169,22 @@ function noDedupe(ctx = new HttpContext()) {
|
|
|
999
1169
|
* ],
|
|
1000
1170
|
* };
|
|
1001
1171
|
*/
|
|
1002
|
-
function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS']) {
|
|
1172
|
+
function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS'], keyFn = hashRequest) {
|
|
1003
1173
|
const inFlight = new Map();
|
|
1004
1174
|
const DEDUPE_METHODS = new Set(allowed);
|
|
1005
1175
|
return (req, next) => {
|
|
1006
1176
|
if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
|
|
1007
1177
|
return next(req);
|
|
1008
|
-
const
|
|
1178
|
+
const key = keyFn(req);
|
|
1179
|
+
const found = inFlight.get(key);
|
|
1009
1180
|
if (found)
|
|
1010
1181
|
return found;
|
|
1011
|
-
const request = next(req).pipe(finalize(() => inFlight.delete(
|
|
1012
|
-
inFlight.set(
|
|
1182
|
+
const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
|
|
1183
|
+
inFlight.set(key, request);
|
|
1013
1184
|
return request;
|
|
1014
1185
|
};
|
|
1015
1186
|
}
|
|
1016
1187
|
|
|
1017
|
-
/**
|
|
1018
|
-
* Checks if `value` is a plain JavaScript object (e.g., `{}` or `new Object()`).
|
|
1019
|
-
* Distinguishes from arrays, null, and class instances. Acts as a type predicate,
|
|
1020
|
-
* narrowing `value` to `UnknownObject` if `true`.
|
|
1021
|
-
*
|
|
1022
|
-
* @param value The value to check.
|
|
1023
|
-
* @returns {value is UnknownObject} `true` if `value` is a plain object, otherwise `false`.
|
|
1024
|
-
* @example
|
|
1025
|
-
* isPlainObject({}) // => true
|
|
1026
|
-
* isPlainObject([]) // => false
|
|
1027
|
-
* isPlainObject(null) // => false
|
|
1028
|
-
* isPlainObject(new Date()) // => false
|
|
1029
|
-
*/
|
|
1030
|
-
function isPlainObject(value) {
|
|
1031
|
-
if (value === null || typeof value !== 'object')
|
|
1032
|
-
return false;
|
|
1033
|
-
const proto = Object.getPrototypeOf(value);
|
|
1034
|
-
if (proto === null)
|
|
1035
|
-
return false; // remove Object.create(null);
|
|
1036
|
-
return proto === Object.prototype;
|
|
1037
|
-
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Internal helper to generate a stable JSON string from an array.
|
|
1040
|
-
* Sorts keys of plain objects within the array alphabetically before serialization
|
|
1041
|
-
* to ensure hash stability regardless of key order.
|
|
1042
|
-
*
|
|
1043
|
-
* @param queryKey The array of values to serialize.
|
|
1044
|
-
* @returns A stable JSON string representation.
|
|
1045
|
-
* @internal
|
|
1046
|
-
*/
|
|
1047
|
-
function hashKey(queryKey) {
|
|
1048
|
-
return JSON.stringify(queryKey, (_, val) => isPlainObject(val)
|
|
1049
|
-
? Object.keys(val)
|
|
1050
|
-
.toSorted()
|
|
1051
|
-
.reduce((result, key) => {
|
|
1052
|
-
result[key] = val[key];
|
|
1053
|
-
return result;
|
|
1054
|
-
}, {})
|
|
1055
|
-
: val);
|
|
1056
|
-
}
|
|
1057
|
-
/**
|
|
1058
|
-
* Generates a stable, unique string hash from one or more arguments.
|
|
1059
|
-
* Useful for creating cache keys or identifiers where object key order shouldn't matter.
|
|
1060
|
-
*
|
|
1061
|
-
* How it works:
|
|
1062
|
-
* - Plain objects within the arguments have their keys sorted alphabetically before hashing.
|
|
1063
|
-
* This ensures that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
|
|
1064
|
-
* - Uses `JSON.stringify` internally with custom sorting for plain objects via `hashKey`.
|
|
1065
|
-
* - Non-plain objects (arrays, Dates, etc.) and primitives are serialized naturally.
|
|
1066
|
-
*
|
|
1067
|
-
* @param {...unknown} args Values to include in the hash.
|
|
1068
|
-
* @returns A stable string hash representing the input arguments.
|
|
1069
|
-
* @example
|
|
1070
|
-
* const userQuery = (id: number) => ['user', { id, timestamp: Date.now() }];
|
|
1071
|
-
*
|
|
1072
|
-
* const obj1 = { a: 1, b: 2 };
|
|
1073
|
-
* const obj2 = { b: 2, a: 1 }; // Same keys/values, different order
|
|
1074
|
-
*
|
|
1075
|
-
* hash('posts', 10);
|
|
1076
|
-
* // => '["posts",10]'
|
|
1077
|
-
*
|
|
1078
|
-
* hash('config', obj1);
|
|
1079
|
-
* // => '["config",{"a":1,"b":2}]'
|
|
1080
|
-
*
|
|
1081
|
-
* hash('config', obj2);
|
|
1082
|
-
* // => '["config",{"a":1,"b":2}]' (Same as above due to key sorting)
|
|
1083
|
-
*
|
|
1084
|
-
* hash(['todos', { status: 'done', owner: obj1 }]);
|
|
1085
|
-
* // => '[["todos",{"owner":{"a":1,"b":2},"status":"done"}]]'
|
|
1086
|
-
*
|
|
1087
|
-
* // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
|
|
1088
|
-
* // hash('a', undefined, function() {}) => '["a",null,null]'
|
|
1089
|
-
*/
|
|
1090
|
-
function hash(...args) {
|
|
1091
|
-
return hashKey(args);
|
|
1092
|
-
}
|
|
1093
1188
|
function equalTransferCache(a, b) {
|
|
1094
1189
|
if (!a && !b)
|
|
1095
1190
|
return true;
|
|
@@ -1337,7 +1432,7 @@ function retryOnError(res, opt, onError) {
|
|
|
1337
1432
|
retries++;
|
|
1338
1433
|
if (timeout)
|
|
1339
1434
|
clearTimeout(timeout);
|
|
1340
|
-
setTimeout(() => res.reload(), retries <= 0 ? 0 : backoff * Math.pow(2, retries - 1));
|
|
1435
|
+
timeout = setTimeout(() => res.reload(), retries <= 0 ? 0 : backoff * Math.pow(2, retries - 1));
|
|
1341
1436
|
};
|
|
1342
1437
|
const onSuccess = () => {
|
|
1343
1438
|
if (timeout)
|
|
@@ -1394,29 +1489,6 @@ function toResourceObject(res) {
|
|
|
1394
1489
|
};
|
|
1395
1490
|
}
|
|
1396
1491
|
|
|
1397
|
-
function normalizeParams(params) {
|
|
1398
|
-
if (params instanceof HttpParams)
|
|
1399
|
-
return params.toString();
|
|
1400
|
-
const paramMap = new Map();
|
|
1401
|
-
for (const [key, value] of Object.entries(params)) {
|
|
1402
|
-
if (Array.isArray(value)) {
|
|
1403
|
-
paramMap.set(key, value.map(encodeURIComponent).join(','));
|
|
1404
|
-
}
|
|
1405
|
-
else {
|
|
1406
|
-
paramMap.set(key, encodeURIComponent(value.toString()));
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
return Array.from(paramMap.entries())
|
|
1410
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
1411
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
1412
|
-
.join('&');
|
|
1413
|
-
}
|
|
1414
|
-
function urlWithParams(req) {
|
|
1415
|
-
if (!req.params)
|
|
1416
|
-
return req.url;
|
|
1417
|
-
return `${req.url}?${normalizeParams(req.params)}`;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
1492
|
function queryResource(request, options) {
|
|
1421
1493
|
const cache = injectQueryCache(options?.injector);
|
|
1422
1494
|
const destroyRef = options?.injector
|
|
@@ -1460,8 +1532,8 @@ function queryResource(request, options) {
|
|
|
1460
1532
|
},
|
|
1461
1533
|
}]));
|
|
1462
1534
|
const hashFn = typeof options?.cache === 'object'
|
|
1463
|
-
? (options.cache.hash ??
|
|
1464
|
-
:
|
|
1535
|
+
? (options.cache.hash ?? hashRequest)
|
|
1536
|
+
: hashRequest;
|
|
1465
1537
|
const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
|
|
1466
1538
|
const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
|
|
1467
1539
|
const cacheKey = computed(() => {
|
|
@@ -1681,6 +1753,7 @@ function manualQueryResource(request, options) {
|
|
|
1681
1753
|
};
|
|
1682
1754
|
}
|
|
1683
1755
|
|
|
1756
|
+
const NULL_VALUE = Symbol('@mmstack/resource:null');
|
|
1684
1757
|
/**
|
|
1685
1758
|
* Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
|
|
1686
1759
|
* Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
|
|
@@ -1704,17 +1777,17 @@ function mutationResource(request, options = {}) {
|
|
|
1704
1777
|
const { onMutate, onError, onSuccess, onSettled, equal, ...rest } = options;
|
|
1705
1778
|
const requestEqual = createEqualRequest(equal);
|
|
1706
1779
|
const eq = equal ?? Object.is;
|
|
1707
|
-
const next = signal(
|
|
1708
|
-
if (
|
|
1780
|
+
const next = signal(NULL_VALUE, ...(ngDevMode ? [{ debugName: "next", equal: (a, b) => {
|
|
1781
|
+
if (a === NULL_VALUE && b === NULL_VALUE)
|
|
1709
1782
|
return true;
|
|
1710
|
-
if (
|
|
1783
|
+
if (a === NULL_VALUE || b === NULL_VALUE)
|
|
1711
1784
|
return false;
|
|
1712
1785
|
return eq(a, b);
|
|
1713
1786
|
} }] : [{
|
|
1714
1787
|
equal: (a, b) => {
|
|
1715
|
-
if (
|
|
1788
|
+
if (a === NULL_VALUE && b === NULL_VALUE)
|
|
1716
1789
|
return true;
|
|
1717
|
-
if (
|
|
1790
|
+
if (a === NULL_VALUE || b === NULL_VALUE)
|
|
1718
1791
|
return false;
|
|
1719
1792
|
return eq(a, b);
|
|
1720
1793
|
},
|
|
@@ -1723,16 +1796,24 @@ function mutationResource(request, options = {}) {
|
|
|
1723
1796
|
let ctx = undefined;
|
|
1724
1797
|
const queueRef = effect(() => {
|
|
1725
1798
|
const nextInQueue = queue().at(0);
|
|
1726
|
-
if (
|
|
1799
|
+
if (nextInQueue === undefined || next() !== NULL_VALUE)
|
|
1727
1800
|
return;
|
|
1728
1801
|
queue.update((q) => q.slice(1));
|
|
1729
1802
|
const [value, ictx] = nextInQueue;
|
|
1730
|
-
|
|
1731
|
-
|
|
1803
|
+
try {
|
|
1804
|
+
ctx = onMutate?.(value, ictx);
|
|
1805
|
+
next.set(value);
|
|
1806
|
+
}
|
|
1807
|
+
catch (mutationErr) {
|
|
1808
|
+
ctx = undefined;
|
|
1809
|
+
next.set(NULL_VALUE);
|
|
1810
|
+
if (isDevMode())
|
|
1811
|
+
console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
|
|
1812
|
+
}
|
|
1732
1813
|
}, ...(ngDevMode ? [{ debugName: "queueRef" }] : []));
|
|
1733
1814
|
const req = computed(() => {
|
|
1734
1815
|
const nr = next();
|
|
1735
|
-
if (
|
|
1816
|
+
if (nr === NULL_VALUE)
|
|
1736
1817
|
return;
|
|
1737
1818
|
return request(nr) ?? undefined;
|
|
1738
1819
|
}, ...(ngDevMode ? [{ debugName: "req", equal: requestEqual }] : [{
|
|
@@ -1740,20 +1821,20 @@ function mutationResource(request, options = {}) {
|
|
|
1740
1821
|
}]));
|
|
1741
1822
|
const lastValue = linkedSignal(...(ngDevMode ? [{ debugName: "lastValue", source: next,
|
|
1742
1823
|
computation: (next, prev) => {
|
|
1743
|
-
if (next ===
|
|
1824
|
+
if (next === NULL_VALUE && !!prev)
|
|
1744
1825
|
return prev.value;
|
|
1745
1826
|
return next;
|
|
1746
1827
|
} }] : [{
|
|
1747
1828
|
source: next,
|
|
1748
1829
|
computation: (next, prev) => {
|
|
1749
|
-
if (next ===
|
|
1830
|
+
if (next === NULL_VALUE && !!prev)
|
|
1750
1831
|
return prev.value;
|
|
1751
1832
|
return next;
|
|
1752
1833
|
},
|
|
1753
1834
|
}]));
|
|
1754
1835
|
const lastValueRequest = computed(() => {
|
|
1755
1836
|
const nr = lastValue();
|
|
1756
|
-
if (
|
|
1837
|
+
if (nr === NULL_VALUE)
|
|
1757
1838
|
return;
|
|
1758
1839
|
return request(nr) ?? undefined;
|
|
1759
1840
|
}, ...(ngDevMode ? [{ debugName: "lastValueRequest", equal: requestEqual }] : [{
|
|
@@ -1765,13 +1846,13 @@ function mutationResource(request, options = {}) {
|
|
|
1765
1846
|
const resource = queryResource(req, {
|
|
1766
1847
|
...rest,
|
|
1767
1848
|
circuitBreaker: cb,
|
|
1768
|
-
defaultValue:
|
|
1849
|
+
defaultValue: NULL_VALUE, // doesnt matter since .value is not accessible
|
|
1769
1850
|
});
|
|
1770
1851
|
const destroyRef = options.injector
|
|
1771
1852
|
? options.injector.get(DestroyRef)
|
|
1772
1853
|
: inject(DestroyRef);
|
|
1773
1854
|
const error$ = toObservable(resource.error);
|
|
1774
|
-
const value$ = toObservable(resource.value).pipe(catchError(() => of(
|
|
1855
|
+
const value$ = toObservable(resource.value).pipe(catchError(() => of(NULL_VALUE)));
|
|
1775
1856
|
const statusSub = toObservable(resource.status)
|
|
1776
1857
|
.pipe(combineLatestWith(error$, value$), map(([status, error, value]) => {
|
|
1777
1858
|
if (status === 'error' && error) {
|
|
@@ -1780,14 +1861,14 @@ function mutationResource(request, options = {}) {
|
|
|
1780
1861
|
error,
|
|
1781
1862
|
};
|
|
1782
1863
|
}
|
|
1783
|
-
if (status === 'resolved' && value !==
|
|
1864
|
+
if (status === 'resolved' && value !== NULL_VALUE) {
|
|
1784
1865
|
return {
|
|
1785
1866
|
status: 'resolved',
|
|
1786
1867
|
value,
|
|
1787
1868
|
};
|
|
1788
1869
|
}
|
|
1789
|
-
return
|
|
1790
|
-
}), filter((v) => v !==
|
|
1870
|
+
return NULL_VALUE;
|
|
1871
|
+
}), filter((v) => v !== NULL_VALUE), takeUntilDestroyed(destroyRef))
|
|
1791
1872
|
.subscribe((result) => {
|
|
1792
1873
|
if (result.status === 'error')
|
|
1793
1874
|
onError?.(result.error, ctx);
|
|
@@ -1795,7 +1876,7 @@ function mutationResource(request, options = {}) {
|
|
|
1795
1876
|
onSuccess?.(result.value, ctx);
|
|
1796
1877
|
onSettled?.(ctx);
|
|
1797
1878
|
ctx = undefined;
|
|
1798
|
-
next.set(
|
|
1879
|
+
next.set(NULL_VALUE);
|
|
1799
1880
|
});
|
|
1800
1881
|
const shouldQueue = options.queue ?? false;
|
|
1801
1882
|
return {
|
|
@@ -1810,11 +1891,22 @@ function mutationResource(request, options = {}) {
|
|
|
1810
1891
|
return queue.update((q) => [...q, [value, ictx]]);
|
|
1811
1892
|
}
|
|
1812
1893
|
else {
|
|
1813
|
-
|
|
1814
|
-
|
|
1894
|
+
try {
|
|
1895
|
+
ctx = onMutate?.(value, ictx);
|
|
1896
|
+
next.set(value);
|
|
1897
|
+
}
|
|
1898
|
+
catch (mutationErr) {
|
|
1899
|
+
ctx = undefined;
|
|
1900
|
+
next.set(NULL_VALUE);
|
|
1901
|
+
if (isDevMode())
|
|
1902
|
+
console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
|
|
1903
|
+
}
|
|
1815
1904
|
}
|
|
1816
1905
|
},
|
|
1817
|
-
current:
|
|
1906
|
+
current: computed(() => {
|
|
1907
|
+
const nv = next();
|
|
1908
|
+
return nv === NULL_VALUE ? null : nv;
|
|
1909
|
+
}),
|
|
1818
1910
|
// redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
|
|
1819
1911
|
disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
|
|
1820
1912
|
};
|