@mmstack/resource 19.3.1 → 19.3.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 +508 -73
- package/fesm2022/mmstack-resource.mjs +125 -42
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/lib/query-resource.d.ts +21 -4
- package/lib/util/cache/cache.d.ts +18 -0
- package/lib/util/circuit-breaker.d.ts +38 -3
- package/lib/util/retry-on-error.d.ts +9 -1
- package/package.json +1 -1
|
@@ -193,6 +193,12 @@ class Cache {
|
|
|
193
193
|
const value = syncTabs.deserialize(msg.entry.value);
|
|
194
194
|
if (value === null)
|
|
195
195
|
return;
|
|
196
|
+
// Last-write-wins by `updated` timestamp. If our local entry was
|
|
197
|
+
// written more recently than the broadcast we just received, the
|
|
198
|
+
// broadcast is stale (in-flight when we wrote locally) — drop it.
|
|
199
|
+
const existing = untracked(this.internal).get(msg.entry.key);
|
|
200
|
+
if (existing && existing.updated >= msg.entry.updated)
|
|
201
|
+
return;
|
|
196
202
|
this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
|
|
197
203
|
}
|
|
198
204
|
else if (msg.action === 'invalidate') {
|
|
@@ -340,6 +346,31 @@ class Cache {
|
|
|
340
346
|
invalidate(key) {
|
|
341
347
|
this.invalidateInternal(key);
|
|
342
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Invalidates every cache entry whose key starts with `prefix`. Common after a
|
|
351
|
+
* list-mutating operation (e.g. invalidate every paginated `GET /api/posts*`
|
|
352
|
+
* after a POST). Returns the number of entries removed.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* cache.invalidatePrefix('GET https://api.example.com/posts');
|
|
356
|
+
*/
|
|
357
|
+
invalidatePrefix(prefix) {
|
|
358
|
+
return this.invalidateWhere((key) => key.startsWith(prefix));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Invalidates every cache entry whose key matches the predicate. Use for
|
|
362
|
+
* arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
|
|
363
|
+
* "everything containing `userId=42`"). Returns the number of entries removed.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* cache.invalidateWhere((key) => key.includes('/me/'));
|
|
367
|
+
*/
|
|
368
|
+
invalidateWhere(predicate) {
|
|
369
|
+
const keys = Array.from(untracked(this.internal).keys()).filter(predicate);
|
|
370
|
+
for (const key of keys)
|
|
371
|
+
this.invalidateInternal(key);
|
|
372
|
+
return keys.length;
|
|
373
|
+
}
|
|
343
374
|
invalidateInternal(key, fromSync = false) {
|
|
344
375
|
const entry = this.getUntracked(key);
|
|
345
376
|
if (!entry)
|
|
@@ -682,7 +713,9 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
682
713
|
});
|
|
683
714
|
}
|
|
684
715
|
return next(req).pipe(tap((event) => {
|
|
685
|
-
if (event instanceof HttpResponse
|
|
716
|
+
if (!(event instanceof HttpResponse))
|
|
717
|
+
return;
|
|
718
|
+
if (event.ok) {
|
|
686
719
|
const cacheControl = parseCacheControlHeader(event);
|
|
687
720
|
if (cacheControl.noStore && !opt.ignoreCacheControl)
|
|
688
721
|
return;
|
|
@@ -701,6 +734,17 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
701
734
|
})
|
|
702
735
|
: event;
|
|
703
736
|
cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
// 304 → server confirmed our cached entry is still valid. Re-stamp the
|
|
740
|
+
// existing entry so subsequent reads within the new freshness window
|
|
741
|
+
// don't trigger another revalidation round-trip.
|
|
742
|
+
if (event.status === 304 && entry) {
|
|
743
|
+
const cacheControl = parseCacheControlHeader(event);
|
|
744
|
+
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
745
|
+
? opt
|
|
746
|
+
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
747
|
+
cache.store(key, entry.value, staleTime, ttl, opt.persist);
|
|
704
748
|
}
|
|
705
749
|
}), map((event) => {
|
|
706
750
|
// handle 304 responses due to eTag/last-modified
|
|
@@ -728,17 +772,18 @@ function catchValueError(resource, fallback) {
|
|
|
728
772
|
|
|
729
773
|
/** @internal */
|
|
730
774
|
const DEFAULT_OPTIONS = {
|
|
731
|
-
|
|
775
|
+
threshold: 5,
|
|
732
776
|
timeout: 30000,
|
|
733
777
|
shouldFail: () => true,
|
|
734
778
|
shouldFailForever: () => false,
|
|
735
779
|
};
|
|
736
780
|
/** @internal */
|
|
737
|
-
function internalCeateCircuitBreaker(
|
|
781
|
+
function internalCeateCircuitBreaker(threshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
|
|
738
782
|
const halfOpen = signal(false);
|
|
739
783
|
const failureCount = signal(0);
|
|
784
|
+
const failedForever = signal(false);
|
|
740
785
|
const status = computed(() => {
|
|
741
|
-
if (failureCount() >=
|
|
786
|
+
if (failedForever() || failureCount() >= threshold)
|
|
742
787
|
return 'OPEN';
|
|
743
788
|
return halfOpen() ? 'HALF_OPEN' : 'CLOSED';
|
|
744
789
|
});
|
|
@@ -752,17 +797,17 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
752
797
|
if (!untracked(isOpen))
|
|
753
798
|
return;
|
|
754
799
|
halfOpen.set(true);
|
|
755
|
-
failureCount.set(
|
|
800
|
+
failureCount.set(threshold - 1);
|
|
756
801
|
};
|
|
757
|
-
|
|
802
|
+
// Auto-probe effect: schedules a half-open retry after `resetTimeout` whenever
|
|
803
|
+
// the breaker is open, *unless* we've been failed forever (in which case only
|
|
804
|
+
// hardReset() can recover).
|
|
758
805
|
const effectRef = effect((cleanup) => {
|
|
759
|
-
if (!isOpen())
|
|
806
|
+
if (!isOpen() || failedForever())
|
|
760
807
|
return;
|
|
761
808
|
const timeout = setTimeout(tryOnce, resetTimeout);
|
|
762
|
-
failForeverResetId = timeout;
|
|
763
809
|
return cleanup(() => {
|
|
764
810
|
clearTimeout(timeout);
|
|
765
|
-
failForeverResetId = null;
|
|
766
811
|
});
|
|
767
812
|
});
|
|
768
813
|
const failInternal = () => {
|
|
@@ -770,12 +815,8 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
770
815
|
halfOpen.set(false);
|
|
771
816
|
};
|
|
772
817
|
const failForever = () => {
|
|
773
|
-
|
|
774
|
-
clearTimeout(failForeverResetId);
|
|
775
|
-
effectRef.destroy();
|
|
776
|
-
failureCount.set(Infinity);
|
|
818
|
+
failedForever.set(true);
|
|
777
819
|
halfOpen.set(false);
|
|
778
|
-
return;
|
|
779
820
|
};
|
|
780
821
|
const fail = (err) => {
|
|
781
822
|
if (shouldFailForever(err))
|
|
@@ -784,6 +825,11 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
784
825
|
return failInternal();
|
|
785
826
|
// If the error does not trigger a failure, we do nothing.
|
|
786
827
|
};
|
|
828
|
+
const hardReset = () => {
|
|
829
|
+
failedForever.set(false);
|
|
830
|
+
failureCount.set(0);
|
|
831
|
+
halfOpen.set(false);
|
|
832
|
+
};
|
|
787
833
|
return {
|
|
788
834
|
status,
|
|
789
835
|
isClosed,
|
|
@@ -791,6 +837,7 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
791
837
|
fail,
|
|
792
838
|
success,
|
|
793
839
|
halfOpen: tryOnce,
|
|
840
|
+
hardReset,
|
|
794
841
|
destroy: () => effectRef.destroy(),
|
|
795
842
|
};
|
|
796
843
|
}
|
|
@@ -809,18 +856,43 @@ function createNeverBrokenCircuitBreaker() {
|
|
|
809
856
|
halfOpen: () => {
|
|
810
857
|
// noop
|
|
811
858
|
},
|
|
859
|
+
hardReset: () => {
|
|
860
|
+
// noop
|
|
861
|
+
},
|
|
812
862
|
destroy: () => {
|
|
813
863
|
// noop
|
|
814
864
|
},
|
|
815
865
|
};
|
|
816
866
|
}
|
|
817
867
|
const CB_DEFAULT_OPTIONS = new InjectionToken('MMSTACK_CIRCUIT_BREAKER_DEFAULT_OPTIONS');
|
|
868
|
+
/**
|
|
869
|
+
* Provides application-wide default options for {@link createCircuitBreaker}.
|
|
870
|
+
* Any `createCircuitBreaker()` call without explicit options (or with only
|
|
871
|
+
* partial options) merges these defaults in, so you can centralize threshold /
|
|
872
|
+
* timeout / failure-classifier behavior in one place.
|
|
873
|
+
*
|
|
874
|
+
* Per-call options always win over the provided defaults.
|
|
875
|
+
*
|
|
876
|
+
* @example
|
|
877
|
+
* ```ts
|
|
878
|
+
* bootstrapApplication(AppComponent, {
|
|
879
|
+
* providers: [
|
|
880
|
+
* provideCircuitBreakerDefaultOptions({
|
|
881
|
+
* threshold: 10,
|
|
882
|
+
* timeout: 60_000,
|
|
883
|
+
* shouldFailForever: (err) =>
|
|
884
|
+
* err instanceof HttpErrorResponse && [401, 403].includes(err.status),
|
|
885
|
+
* }),
|
|
886
|
+
* ],
|
|
887
|
+
* });
|
|
888
|
+
* ```
|
|
889
|
+
*/
|
|
818
890
|
function provideCircuitBreakerDefaultOptions(options) {
|
|
819
891
|
return {
|
|
820
892
|
provide: CB_DEFAULT_OPTIONS,
|
|
821
893
|
useValue: {
|
|
822
894
|
...DEFAULT_OPTIONS,
|
|
823
|
-
...options,
|
|
895
|
+
...normalizeThreshold(options),
|
|
824
896
|
},
|
|
825
897
|
};
|
|
826
898
|
}
|
|
@@ -829,12 +901,23 @@ function injectCircuitBreakerOptions(injector = inject(Injector)) {
|
|
|
829
901
|
optional: true,
|
|
830
902
|
});
|
|
831
903
|
}
|
|
904
|
+
/** @internal — strips the deprecated `treshold` field and folds it into `threshold` */
|
|
905
|
+
function normalizeThreshold(opt) {
|
|
906
|
+
if (!opt || typeof opt !== 'object' || 'isClosed' in opt)
|
|
907
|
+
return {};
|
|
908
|
+
const { treshold, threshold, ...rest } = opt;
|
|
909
|
+
return {
|
|
910
|
+
...rest,
|
|
911
|
+
threshold: threshold ?? treshold,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
832
914
|
/**
|
|
833
915
|
* Creates a circuit breaker instance.
|
|
834
916
|
*
|
|
835
917
|
* @param options - Configuration options for the circuit breaker. Can be:
|
|
836
|
-
* - `undefined`:
|
|
837
|
-
* - `
|
|
918
|
+
* - `undefined`: Uses defaults (threshold: 5, timeout: 30000ms) or provided defaults via {@link provideCircuitBreakerDefaultOptions}.
|
|
919
|
+
* - `false`: Creates a "no-op" circuit breaker that is always closed (never trips).
|
|
920
|
+
* - `true`: Creates a circuit breaker with default settings.
|
|
838
921
|
* - `CircuitBreaker`: Reuses an existing `CircuitBreaker` instance.
|
|
839
922
|
* - `{ threshold?: number; timeout?: number; }`: Creates a circuit breaker with the specified threshold and timeout.
|
|
840
923
|
*
|
|
@@ -857,11 +940,11 @@ function createCircuitBreaker(opt, injector) {
|
|
|
857
940
|
return createNeverBrokenCircuitBreaker();
|
|
858
941
|
if (typeof opt === 'object' && 'isClosed' in opt)
|
|
859
942
|
return opt;
|
|
860
|
-
const {
|
|
943
|
+
const { threshold, timeout, shouldFail, shouldFailForever } = {
|
|
861
944
|
...injectCircuitBreakerOptions(injector),
|
|
862
|
-
...opt,
|
|
945
|
+
...normalizeThreshold(opt),
|
|
863
946
|
};
|
|
864
|
-
return internalCeateCircuitBreaker(
|
|
947
|
+
return internalCeateCircuitBreaker(threshold, timeout, shouldFail, shouldFailForever);
|
|
865
948
|
}
|
|
866
949
|
|
|
867
950
|
// Heavily inspired by: https://dev.to/kasual1/request-deduplication-in-angular-3pd8
|
|
@@ -1234,13 +1317,16 @@ function refresh(resource, destroyRef, refresh) {
|
|
|
1234
1317
|
}
|
|
1235
1318
|
|
|
1236
1319
|
// Retry on error, if number is provided it will retry that many times with exponential backoff, otherwise it will use the options provided
|
|
1237
|
-
function retryOnError(res, opt) {
|
|
1320
|
+
function retryOnError(res, opt, onError) {
|
|
1238
1321
|
const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
|
|
1239
1322
|
const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
|
|
1240
1323
|
let retries = 0;
|
|
1241
1324
|
let timeout;
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1325
|
+
const handleError = () => {
|
|
1326
|
+
const err = untracked(res.error);
|
|
1327
|
+
const isFinal = retries >= max;
|
|
1328
|
+
onError?.(err, retries, isFinal);
|
|
1329
|
+
if (isFinal)
|
|
1244
1330
|
return;
|
|
1245
1331
|
retries++;
|
|
1246
1332
|
if (timeout)
|
|
@@ -1255,7 +1341,7 @@ function retryOnError(res, opt) {
|
|
|
1255
1341
|
const ref = effect(() => {
|
|
1256
1342
|
switch (res.status()) {
|
|
1257
1343
|
case ResourceStatus.Error:
|
|
1258
|
-
return
|
|
1344
|
+
return handleError();
|
|
1259
1345
|
case ResourceStatus.Resolved:
|
|
1260
1346
|
return onSuccess();
|
|
1261
1347
|
}
|
|
@@ -1337,10 +1423,20 @@ function queryResource(request, options) {
|
|
|
1337
1423
|
const eq = options?.triggerOnSameRequest
|
|
1338
1424
|
? undefined
|
|
1339
1425
|
: createEqualRequest(options?.equal);
|
|
1426
|
+
const rawRequest = computed(() => request() ?? undefined);
|
|
1427
|
+
const disabledReason = computed(() => {
|
|
1428
|
+
if (!networkAvailable())
|
|
1429
|
+
return 'offline';
|
|
1430
|
+
if (cb.isOpen())
|
|
1431
|
+
return 'circuit-open';
|
|
1432
|
+
if (!rawRequest())
|
|
1433
|
+
return 'no-request';
|
|
1434
|
+
return null;
|
|
1435
|
+
});
|
|
1340
1436
|
const stableRequest = computed(() => {
|
|
1341
|
-
if (
|
|
1437
|
+
if (disabledReason() !== null)
|
|
1342
1438
|
return undefined;
|
|
1343
|
-
const req =
|
|
1439
|
+
const req = rawRequest();
|
|
1344
1440
|
if (!req)
|
|
1345
1441
|
return undefined;
|
|
1346
1442
|
if (typeof req === 'string')
|
|
@@ -1416,7 +1512,7 @@ function queryResource(request, options) {
|
|
|
1416
1512
|
},
|
|
1417
1513
|
});
|
|
1418
1514
|
resource = refresh(resource, destroyRef, options?.refresh);
|
|
1419
|
-
resource = retryOnError(resource, options?.retry);
|
|
1515
|
+
resource = retryOnError(resource, options?.retry, options?.onError);
|
|
1420
1516
|
resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
|
|
1421
1517
|
const value = options?.cache
|
|
1422
1518
|
? toWritable(computed(() => {
|
|
@@ -1424,20 +1520,6 @@ function queryResource(request, options) {
|
|
|
1424
1520
|
return cacheEntry()?.value ?? resource.value();
|
|
1425
1521
|
}), resource.value.set, resource.value.update)
|
|
1426
1522
|
: resource.value;
|
|
1427
|
-
const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
|
|
1428
|
-
if (onError) {
|
|
1429
|
-
const onErrorRef = effect(() => {
|
|
1430
|
-
const err = resource.error();
|
|
1431
|
-
if (err)
|
|
1432
|
-
onError(err);
|
|
1433
|
-
});
|
|
1434
|
-
// cleanup on manual destroy, I'm comfortable setting these props in-line as we have yet to 'release' the object out of this lexical scope
|
|
1435
|
-
const destroyRest = resource.destroy;
|
|
1436
|
-
resource.destroy = () => {
|
|
1437
|
-
onErrorRef.destroy();
|
|
1438
|
-
destroyRest();
|
|
1439
|
-
};
|
|
1440
|
-
}
|
|
1441
1523
|
// iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
|
|
1442
1524
|
const cbEffectRef = effect(() => {
|
|
1443
1525
|
const status = resource.status();
|
|
@@ -1469,7 +1551,8 @@ function queryResource(request, options) {
|
|
|
1469
1551
|
update,
|
|
1470
1552
|
statusCode: linkedSignal(resource.statusCode),
|
|
1471
1553
|
headers: linkedSignal(resource.headers),
|
|
1472
|
-
disabled: computed(() =>
|
|
1554
|
+
disabled: computed(() => disabledReason() !== null),
|
|
1555
|
+
disabledReason,
|
|
1473
1556
|
reload: () => {
|
|
1474
1557
|
cb.halfOpen(); // open the circuit for manual reload
|
|
1475
1558
|
return resource.reload();
|