@mmstack/resource 20.5.0 → 20.5.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/index.d.ts +81 -11
- 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, ...(ngDevMode ? [{ debugName: "halfOpen" }] : []));
|
|
739
783
|
const failureCount = signal(0, ...(ngDevMode ? [{ debugName: "failureCount" }] : []));
|
|
784
|
+
const failedForever = signal(false, ...(ngDevMode ? [{ debugName: "failedForever" }] : []));
|
|
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
|
}, ...(ngDevMode ? [{ debugName: "status" }] : []));
|
|
@@ -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
|
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
|
|
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
|
|
@@ -1240,13 +1323,16 @@ function refresh(resource, destroyRef, refresh) {
|
|
|
1240
1323
|
}
|
|
1241
1324
|
|
|
1242
1325
|
// Retry on error, if number is provided it will retry that many times with exponential backoff, otherwise it will use the options provided
|
|
1243
|
-
function retryOnError(res, opt) {
|
|
1326
|
+
function retryOnError(res, opt, onError) {
|
|
1244
1327
|
const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
|
|
1245
1328
|
const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
|
|
1246
1329
|
let retries = 0;
|
|
1247
1330
|
let timeout;
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1331
|
+
const handleError = () => {
|
|
1332
|
+
const err = untracked(res.error);
|
|
1333
|
+
const isFinal = retries >= max;
|
|
1334
|
+
onError?.(err, retries, isFinal);
|
|
1335
|
+
if (isFinal)
|
|
1250
1336
|
return;
|
|
1251
1337
|
retries++;
|
|
1252
1338
|
if (timeout)
|
|
@@ -1261,7 +1347,7 @@ function retryOnError(res, opt) {
|
|
|
1261
1347
|
const ref = effect(() => {
|
|
1262
1348
|
switch (res.status()) {
|
|
1263
1349
|
case 'error':
|
|
1264
|
-
return
|
|
1350
|
+
return handleError();
|
|
1265
1351
|
case 'resolved':
|
|
1266
1352
|
return onSuccess();
|
|
1267
1353
|
}
|
|
@@ -1343,10 +1429,20 @@ function queryResource(request, options) {
|
|
|
1343
1429
|
const eq = options?.triggerOnSameRequest
|
|
1344
1430
|
? undefined
|
|
1345
1431
|
: createEqualRequest(options?.equal);
|
|
1432
|
+
const rawRequest = computed(() => request() ?? undefined, ...(ngDevMode ? [{ debugName: "rawRequest" }] : []));
|
|
1433
|
+
const disabledReason = computed(() => {
|
|
1434
|
+
if (!networkAvailable())
|
|
1435
|
+
return 'offline';
|
|
1436
|
+
if (cb.isOpen())
|
|
1437
|
+
return 'circuit-open';
|
|
1438
|
+
if (!rawRequest())
|
|
1439
|
+
return 'no-request';
|
|
1440
|
+
return null;
|
|
1441
|
+
}, ...(ngDevMode ? [{ debugName: "disabledReason" }] : []));
|
|
1346
1442
|
const stableRequest = computed(() => {
|
|
1347
|
-
if (
|
|
1443
|
+
if (disabledReason() !== null)
|
|
1348
1444
|
return undefined;
|
|
1349
|
-
const req =
|
|
1445
|
+
const req = rawRequest();
|
|
1350
1446
|
if (!req)
|
|
1351
1447
|
return undefined;
|
|
1352
1448
|
if (typeof req === 'string')
|
|
@@ -1444,7 +1540,7 @@ function queryResource(request, options) {
|
|
|
1444
1540
|
},
|
|
1445
1541
|
}]));
|
|
1446
1542
|
resource = refresh(resource, destroyRef, options?.refresh);
|
|
1447
|
-
resource = retryOnError(resource, options?.retry);
|
|
1543
|
+
resource = retryOnError(resource, options?.retry, options?.onError);
|
|
1448
1544
|
resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
|
|
1449
1545
|
const value = options?.cache
|
|
1450
1546
|
? toWritable(computed(() => {
|
|
@@ -1452,20 +1548,6 @@ function queryResource(request, options) {
|
|
|
1452
1548
|
return cacheEntry()?.value ?? resource.value();
|
|
1453
1549
|
}), resource.value.set, resource.value.update)
|
|
1454
1550
|
: resource.value;
|
|
1455
|
-
const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
|
|
1456
|
-
if (onError) {
|
|
1457
|
-
const onErrorRef = effect(() => {
|
|
1458
|
-
const err = resource.error();
|
|
1459
|
-
if (err)
|
|
1460
|
-
onError(err);
|
|
1461
|
-
}, ...(ngDevMode ? [{ debugName: "onErrorRef" }] : []));
|
|
1462
|
-
// 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
|
|
1463
|
-
const destroyRest = resource.destroy;
|
|
1464
|
-
resource.destroy = () => {
|
|
1465
|
-
onErrorRef.destroy();
|
|
1466
|
-
destroyRest();
|
|
1467
|
-
};
|
|
1468
|
-
}
|
|
1469
1551
|
// iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
|
|
1470
1552
|
const cbEffectRef = effect(() => {
|
|
1471
1553
|
const status = resource.status();
|
|
@@ -1497,7 +1579,8 @@ function queryResource(request, options) {
|
|
|
1497
1579
|
update,
|
|
1498
1580
|
statusCode: linkedSignal(resource.statusCode),
|
|
1499
1581
|
headers: linkedSignal(resource.headers),
|
|
1500
|
-
disabled: computed(() =>
|
|
1582
|
+
disabled: computed(() => disabledReason() !== null),
|
|
1583
|
+
disabledReason,
|
|
1501
1584
|
reload: () => {
|
|
1502
1585
|
cb.halfOpen(); // open the circuit for manual reload
|
|
1503
1586
|
return resource.reload();
|