@mmstack/resource 19.2.0 → 19.3.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.
@@ -1,11 +1,109 @@
1
- import { computed, untracked, InjectionToken, inject, isDevMode, signal, effect, linkedSignal, ResourceStatus, DestroyRef } from '@angular/core';
1
+ import * as i0 from '@angular/core';
2
+ import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, ResourceStatus, Injectable, DestroyRef } from '@angular/core';
3
+ import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
4
+ import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext, HttpParams, httpResource, HttpClient } from '@angular/common/http';
5
+ import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
2
6
  import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
3
- import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, combineLatestWith, filter } from 'rxjs';
4
- import { HttpContextToken, HttpContext, HttpResponse, HttpParams, httpResource, HttpClient } from '@angular/common/http';
5
- import { mutable, toWritable } from '@mmstack/primitives';
6
- import { v7 } from 'uuid';
7
- import { keys, hash, entries } from '@mmstack/object';
8
7
 
8
+ function createNoopDB() {
9
+ return {
10
+ getAll: async () => [],
11
+ store: async () => {
12
+ // noop
13
+ },
14
+ remove: async () => {
15
+ // noop
16
+ },
17
+ };
18
+ }
19
+ function toCacheDB(db, storeName) {
20
+ const getAll = async () => {
21
+ const now = Date.now();
22
+ return new Promise((res, rej) => {
23
+ const transaction = db.transaction(storeName, 'readonly');
24
+ const store = transaction.objectStore(storeName);
25
+ const request = store.getAll();
26
+ request.onsuccess = () => res(request.result);
27
+ request.onerror = () => rej(request.error);
28
+ })
29
+ .then((entries) => entries.filter((e) => e.expiresAt > now))
30
+ .catch((err) => {
31
+ if (isDevMode())
32
+ console.error('Error getting all items from cache DB:', err);
33
+ return [];
34
+ });
35
+ };
36
+ const store = (value) => {
37
+ return new Promise((res, rej) => {
38
+ const transaction = db.transaction(storeName, 'readwrite');
39
+ const store = transaction.objectStore(storeName);
40
+ store.put(value);
41
+ transaction.oncomplete = () => res();
42
+ transaction.onerror = () => rej(transaction.error);
43
+ }).catch((err) => {
44
+ if (isDevMode())
45
+ console.error('Error storing item in cache DB:', err);
46
+ });
47
+ };
48
+ const remove = (key) => {
49
+ return new Promise((res, rej) => {
50
+ const transaction = db.transaction(storeName, 'readwrite');
51
+ const store = transaction.objectStore(storeName);
52
+ store.delete(key);
53
+ transaction.oncomplete = () => res();
54
+ transaction.onerror = () => rej(transaction.error);
55
+ }).catch((err) => {
56
+ if (isDevMode())
57
+ console.error('Error removing item from cache DB:', err);
58
+ });
59
+ };
60
+ return {
61
+ getAll,
62
+ store,
63
+ remove,
64
+ };
65
+ }
66
+ function createSingleStoreDB(name, getStoreName, version = 1) {
67
+ const storeName = getStoreName(version);
68
+ if (!globalThis.indexedDB)
69
+ return Promise.resolve(createNoopDB());
70
+ return new Promise((res, rej) => {
71
+ if (version < 1)
72
+ rej(new Error('Version must be 1 or greater'));
73
+ const req = indexedDB.open(name, version);
74
+ req.onupgradeneeded = (event) => {
75
+ const db = req.result;
76
+ const oldVersion = event.oldVersion;
77
+ db.createObjectStore(storeName, { keyPath: 'key' });
78
+ if (oldVersion > 0) {
79
+ db.deleteObjectStore(getStoreName(oldVersion));
80
+ }
81
+ };
82
+ req.onerror = () => {
83
+ rej(req.error);
84
+ };
85
+ req.onsuccess = () => res(req.result);
86
+ })
87
+ .then((db) => toCacheDB(db, storeName))
88
+ .catch((err) => {
89
+ if (isDevMode())
90
+ console.error('Error creating query DB:', err);
91
+ return createNoopDB();
92
+ });
93
+ }
94
+
95
+ function generateID() {
96
+ if (globalThis.crypto?.randomUUID) {
97
+ return globalThis.crypto.randomUUID();
98
+ }
99
+ return Math.random().toString(36).substring(2);
100
+ }
101
+ function isSyncMessage(msg) {
102
+ return (typeof msg === 'object' &&
103
+ msg !== null &&
104
+ 'type' in msg &&
105
+ msg.type === 'cache-sync-message');
106
+ }
9
107
  const ONE_DAY = 1000 * 60 * 60 * 24;
10
108
  const ONE_HOUR = 1000 * 60 * 60;
11
109
  const DEFAULT_CLEANUP_OPT = {
@@ -21,8 +119,18 @@ const DEFAULT_CLEANUP_OPT = {
21
119
  class Cache {
22
120
  ttl;
23
121
  staleTime;
122
+ db;
24
123
  internal = mutable(new Map());
25
124
  cleanupOpt;
125
+ id = generateID();
126
+ /**
127
+ * Destroys the cache instance, cleaning up any resources used by the cache.
128
+ * This method is called automatically when the cache instance is garbage collected.
129
+ */
130
+ destroy;
131
+ broadcast = () => {
132
+ // noop
133
+ };
26
134
  /**
27
135
  * Creates a new `Cache` instance.
28
136
  *
@@ -31,14 +139,17 @@ class Cache {
31
139
  * stale but can still be used while revalidation occurs in the background. Defaults to 1 hour.
32
140
  * @param cleanupOpt - Options for configuring the cache cleanup strategy. Defaults to LRU with a
33
141
  * `maxSize` of 200 and a `checkInterval` of one hour.
142
+ * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
143
+ * Defaults to `undefined`, meaning no synchronization across tabs.
34
144
  */
35
145
  constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = {
36
146
  type: 'lru',
37
147
  maxSize: 1000,
38
148
  checkInterval: ONE_HOUR,
39
- }) {
149
+ }, syncTabs, db = Promise.resolve(createNoopDB())) {
40
150
  this.ttl = ttl;
41
151
  this.staleTime = staleTime;
152
+ this.db = db;
42
153
  this.cleanupOpt = {
43
154
  ...DEFAULT_CLEANUP_OPT,
44
155
  ...cleanupOpt,
@@ -49,14 +160,82 @@ class Cache {
49
160
  const cleanupInterval = setInterval(() => {
50
161
  this.cleanup();
51
162
  }, cleanupOpt.checkInterval);
52
- const destroyId = v7();
163
+ let destroySyncTabs = () => {
164
+ // noop
165
+ };
166
+ if (syncTabs) {
167
+ const channel = new BroadcastChannel(syncTabs.id);
168
+ this.broadcast = (msg) => {
169
+ if (msg.action === 'invalidate')
170
+ return channel.postMessage({
171
+ action: 'invalidate',
172
+ entry: { key: msg.entry.key },
173
+ cacheId: this.id,
174
+ type: 'cache-sync-message',
175
+ });
176
+ return channel.postMessage({
177
+ ...msg,
178
+ entry: {
179
+ ...msg.entry,
180
+ value: syncTabs.serialize(msg.entry.value),
181
+ },
182
+ cacheId: this.id,
183
+ type: 'cache-sync-message',
184
+ });
185
+ };
186
+ channel.onmessage = (event) => {
187
+ const msg = event.data;
188
+ if (!isSyncMessage(msg))
189
+ return;
190
+ if (msg.cacheId === this.id)
191
+ return; // ignore messages from this cache
192
+ if (msg.action === 'store') {
193
+ const value = syncTabs.deserialize(msg.entry.value);
194
+ if (value === null)
195
+ return;
196
+ this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
197
+ }
198
+ else if (msg.action === 'invalidate') {
199
+ this.invalidateInternal(msg.entry.key, true);
200
+ }
201
+ };
202
+ destroySyncTabs = () => {
203
+ channel.close();
204
+ };
205
+ }
206
+ let destroyed = false;
207
+ const destroy = () => {
208
+ if (destroyed)
209
+ return;
210
+ destroyed = true;
211
+ clearInterval(cleanupInterval);
212
+ destroySyncTabs();
213
+ };
214
+ this.db
215
+ .then(async (db) => {
216
+ if (destroyed)
217
+ return [];
218
+ return db.getAll();
219
+ })
220
+ .then((entries) => {
221
+ if (destroyed)
222
+ return;
223
+ // load entries into the cache
224
+ const current = untracked(this.internal);
225
+ entries.forEach((entry) => {
226
+ if (current.has(entry.key))
227
+ return;
228
+ this.storeInternal(entry.key, entry.value, entry.stale - entry.updated, entry.expiresAt - entry.updated, true);
229
+ });
230
+ });
231
+ this.destroy = destroy;
53
232
  // cleanup if object is garbage collected, this is because the cache can be quite large from a memory standpoint & we dont want all that floating garbage
54
233
  const registry = new FinalizationRegistry((id) => {
55
- if (id === destroyId) {
56
- clearInterval(cleanupInterval);
234
+ if (id === this.id) {
235
+ destroy();
57
236
  }
58
237
  });
59
- registry.register(this, destroyId);
238
+ registry.register(this, this.id);
60
239
  }
61
240
  /** @internal */
62
241
  getInternal(key) {
@@ -96,6 +275,17 @@ class Cache {
96
275
  get(key) {
97
276
  return this.getInternal(key);
98
277
  }
278
+ /**
279
+ * Retrieves a cache entry or an object with the key if not found.
280
+ *
281
+ * @param key - A function that returns the cache key. The key is a signal, allowing for dynamic keys. If the function returns null the value is also null.
282
+ * @returns A signal that holds the cache entry or an object with the key if not found. The signal
283
+ * updates whenever the cache entry changes (e.g., due to revalidation or expiration).
284
+ */
285
+ getEntryOrKey(key) {
286
+ const valueSig = this.getInternal(key);
287
+ return computed(() => valueSig() ?? key());
288
+ }
99
289
  /**
100
290
  * Stores a value in the cache.
101
291
  *
@@ -104,7 +294,10 @@ class Cache {
104
294
  * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
105
295
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
106
296
  */
107
- store(key, value, staleTime = this.staleTime, ttl = this.ttl) {
297
+ store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
298
+ this.storeInternal(key, value, staleTime, ttl, false, persist);
299
+ }
300
+ storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
108
301
  const entry = this.getUntracked(key);
109
302
  if (entry) {
110
303
  clearTimeout(entry.timeout); // stop invalidation
@@ -114,17 +307,30 @@ class Cache {
114
307
  if (ttl < staleTime)
115
308
  staleTime = ttl;
116
309
  const now = Date.now();
310
+ const next = {
311
+ value,
312
+ created: entry?.created ?? now,
313
+ updated: now,
314
+ useCount: prevCount + 1,
315
+ stale: now + staleTime,
316
+ expiresAt: now + ttl,
317
+ key,
318
+ };
117
319
  this.internal.mutate((map) => {
118
320
  map.set(key, {
119
- value,
120
- created: entry?.created ?? now,
121
- useCount: prevCount + 1,
122
- stale: now + staleTime,
123
- expiresAt: now + ttl,
321
+ ...next,
124
322
  timeout: setTimeout(() => this.invalidate(key), ttl),
125
323
  });
126
324
  return map;
127
325
  });
326
+ if (!fromSync) {
327
+ if (persist)
328
+ this.db.then((db) => db.store(next));
329
+ this.broadcast({
330
+ action: 'store',
331
+ entry: next,
332
+ });
333
+ }
128
334
  }
129
335
  /**
130
336
  * Invalidates (removes) a cache entry.
@@ -132,6 +338,9 @@ class Cache {
132
338
  * @param key - The key of the entry to invalidate.
133
339
  */
134
340
  invalidate(key) {
341
+ this.invalidateInternal(key);
342
+ }
343
+ invalidateInternal(key, fromSync = false) {
135
344
  const entry = this.getUntracked(key);
136
345
  if (!entry)
137
346
  return;
@@ -140,6 +349,10 @@ class Cache {
140
349
  map.delete(key);
141
350
  return map;
142
351
  });
352
+ if (!fromSync) {
353
+ this.db.then((db) => db.remove(key));
354
+ this.broadcast({ action: 'invalidate', entry: { key } });
355
+ }
143
356
  }
144
357
  /** @internal */
145
358
  cleanup() {
@@ -186,9 +399,80 @@ const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
186
399
  * };
187
400
  */
188
401
  function provideQueryCache(opt) {
402
+ const serialize = (value) => {
403
+ const headersRecord = {};
404
+ const headerKeys = value.headers.keys();
405
+ headerKeys.forEach((key) => {
406
+ const values = value.headers.getAll(key);
407
+ if (!values)
408
+ return;
409
+ headersRecord[key] = values;
410
+ });
411
+ return JSON.stringify({
412
+ body: value.body,
413
+ status: value.status,
414
+ statusText: value.statusText,
415
+ headers: headerKeys.length > 0 ? headersRecord : undefined,
416
+ url: value.url,
417
+ });
418
+ };
419
+ const deserialize = (value) => {
420
+ try {
421
+ const parsed = JSON.parse(value);
422
+ if (!parsed || typeof parsed !== 'object' || !('body' in parsed))
423
+ throw new Error('Invalid cache entry format');
424
+ const headers = parsed.headers
425
+ ? new HttpHeaders(parsed.headers)
426
+ : undefined;
427
+ return new HttpResponse({
428
+ body: parsed.body,
429
+ status: parsed.status,
430
+ statusText: parsed.statusText,
431
+ headers: headers,
432
+ url: parsed.url,
433
+ });
434
+ }
435
+ catch (err) {
436
+ if (isDevMode())
437
+ console.error('Failed to deserialize cache entry:', err);
438
+ return null;
439
+ }
440
+ };
441
+ const syncTabsOpt = opt?.syncTabs
442
+ ? {
443
+ id: 'mmstack-query-cache-sync',
444
+ serialize,
445
+ deserialize,
446
+ }
447
+ : undefined;
448
+ const db = opt?.persist === false
449
+ ? undefined
450
+ : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
451
+ return {
452
+ getAll: () => {
453
+ return db.getAll().then((entries) => {
454
+ return entries
455
+ .map((entry) => {
456
+ const value = deserialize(entry.value);
457
+ if (value === null)
458
+ return null;
459
+ return {
460
+ ...entry,
461
+ value,
462
+ };
463
+ })
464
+ .filter((e) => e !== null);
465
+ });
466
+ },
467
+ store: (entry) => {
468
+ return db.store({ ...entry, value: serialize(entry.value) });
469
+ },
470
+ remove: db.remove,
471
+ };
472
+ });
189
473
  return {
190
474
  provide: CLIENT_CACHE_TOKEN,
191
- useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup),
475
+ useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db),
192
476
  };
193
477
  }
194
478
  class NoopCache extends Cache {
@@ -322,30 +606,25 @@ function parseCacheControlHeader(req) {
322
606
  };
323
607
  return directives;
324
608
  }
325
- function resolveTimings(cacheControl, staleTime, ttl) {
326
- const timings = {
327
- staleTime,
328
- ttl,
329
- };
609
+ function resolveTimings(cacheControl, optStaleTime, optTTL) {
610
+ let staleTime = optStaleTime;
611
+ let ttl = optTTL;
330
612
  if (cacheControl.immutable)
331
613
  return {
332
614
  staleTime: Infinity,
333
615
  ttl: Infinity,
334
616
  };
617
+ if (cacheControl.maxAge !== null)
618
+ ttl = cacheControl.maxAge * 1000;
619
+ if (cacheControl.staleWhileRevalidate !== null)
620
+ staleTime = cacheControl.staleWhileRevalidate * 1000;
335
621
  // if no-cache is set, we must always revalidate
336
622
  if (cacheControl.noCache || cacheControl.mustRevalidate)
337
- timings.staleTime = 0;
338
- if (cacheControl.staleWhileRevalidate !== null)
339
- timings.staleTime = cacheControl.staleWhileRevalidate;
340
- if (cacheControl.maxAge !== null)
341
- timings.ttl = cacheControl.maxAge * 1000;
342
- // if stale-while-revalidate is set, we must revalidate after that time at the latest, but we can still serve the stale data
343
- if (cacheControl.staleWhileRevalidate !== null) {
344
- const ms = cacheControl.staleWhileRevalidate * 1000;
345
- if (timings.staleTime === undefined || timings.staleTime > ms)
346
- timings.staleTime = ms;
623
+ staleTime = 0;
624
+ if (ttl !== undefined && staleTime !== undefined && ttl < staleTime) {
625
+ staleTime = ttl;
347
626
  }
348
- return timings;
627
+ return { staleTime, ttl };
349
628
  }
350
629
  /**
351
630
  * Creates an `HttpInterceptorFn` that implements caching for HTTP requests. This interceptor
@@ -397,13 +676,31 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
397
676
  if (lastModified) {
398
677
  req = req.clone({ setHeaders: { 'If-Modified-Since': lastModified } });
399
678
  }
679
+ if (opt.bustBrowserCache) {
680
+ req = req.clone({
681
+ setParams: { _cb: Date.now().toString() },
682
+ });
683
+ }
400
684
  return next(req).pipe(tap((event) => {
401
685
  if (event instanceof HttpResponse && event.ok) {
402
686
  const cacheControl = parseCacheControlHeader(event);
403
- if (cacheControl.noStore)
687
+ if (cacheControl.noStore && !opt.ignoreCacheControl)
404
688
  return;
405
- const { staleTime, ttl } = resolveTimings(cacheControl, opt.staleTime, opt.ttl);
406
- cache.store(key, event, staleTime, ttl);
689
+ const { staleTime, ttl } = opt.ignoreCacheControl
690
+ ? opt
691
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
692
+ if (opt.ttl === 0)
693
+ return; // no point
694
+ const parsedResponse = opt.parse
695
+ ? new HttpResponse({
696
+ body: opt.parse(event.body),
697
+ headers: event.headers,
698
+ status: event.status,
699
+ statusText: event.statusText,
700
+ url: event.url ?? undefined,
701
+ })
702
+ : event;
703
+ cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
407
704
  }
408
705
  }), map((event) => {
409
706
  // handle 304 responses due to eTag/last-modified
@@ -415,39 +712,82 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
415
712
  };
416
713
  }
417
714
 
715
+ function catchValueError(resource, fallback) {
716
+ return {
717
+ ...resource,
718
+ value: toWritable(computed(() => {
719
+ try {
720
+ return resource.value();
721
+ }
722
+ catch {
723
+ return fallback;
724
+ }
725
+ }), (value) => resource.value.set(value)),
726
+ };
727
+ }
728
+
729
+ /** @internal */
730
+ const DEFAULT_OPTIONS = {
731
+ treshold: 5,
732
+ timeout: 30000,
733
+ shouldFail: () => true,
734
+ shouldFailForever: () => false,
735
+ };
418
736
  /** @internal */
419
- function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000) {
737
+ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
420
738
  const halfOpen = signal(false);
421
739
  const failureCount = signal(0);
422
740
  const status = computed(() => {
423
741
  if (failureCount() >= treshold)
424
- return 'CLOSED';
425
- return halfOpen() ? 'HALF_OPEN' : 'OPEN';
742
+ return 'OPEN';
743
+ return halfOpen() ? 'HALF_OPEN' : 'CLOSED';
426
744
  });
427
- const isClosed = computed(() => status() === 'CLOSED');
745
+ const isClosed = computed(() => status() !== 'OPEN');
746
+ const isOpen = computed(() => status() !== 'CLOSED');
428
747
  const success = () => {
429
748
  failureCount.set(0);
430
749
  halfOpen.set(false);
431
750
  };
432
751
  const tryOnce = () => {
433
- if (!untracked(isClosed))
752
+ if (!untracked(isOpen))
434
753
  return;
435
754
  halfOpen.set(true);
436
755
  failureCount.set(treshold - 1);
437
756
  };
757
+ let failForeverResetId = null;
438
758
  const effectRef = effect((cleanup) => {
439
- if (!isClosed())
759
+ if (!isOpen())
440
760
  return;
441
761
  const timeout = setTimeout(tryOnce, resetTimeout);
442
- return cleanup(() => clearTimeout(timeout));
762
+ failForeverResetId = timeout;
763
+ return cleanup(() => {
764
+ clearTimeout(timeout);
765
+ failForeverResetId = null;
766
+ });
443
767
  });
444
- const fail = () => {
768
+ const failInternal = () => {
445
769
  failureCount.set(failureCount() + 1);
446
770
  halfOpen.set(false);
447
771
  };
772
+ const failForever = () => {
773
+ if (failForeverResetId)
774
+ clearTimeout(failForeverResetId);
775
+ effectRef.destroy();
776
+ failureCount.set(Infinity);
777
+ halfOpen.set(false);
778
+ return;
779
+ };
780
+ const fail = (err) => {
781
+ if (shouldFailForever(err))
782
+ return failForever();
783
+ if (shouldFail(err))
784
+ return failInternal();
785
+ // If the error does not trigger a failure, we do nothing.
786
+ };
448
787
  return {
449
788
  status,
450
789
  isClosed,
790
+ isOpen,
451
791
  fail,
452
792
  success,
453
793
  halfOpen: tryOnce,
@@ -457,8 +797,9 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000) {
457
797
  /** @internal */
458
798
  function createNeverBrokenCircuitBreaker() {
459
799
  return {
460
- isClosed: computed(() => false),
461
- status: signal('OPEN'),
800
+ isClosed: computed(() => true),
801
+ isOpen: computed(() => false),
802
+ status: signal('CLOSED'),
462
803
  fail: () => {
463
804
  // noop
464
805
  },
@@ -473,6 +814,21 @@ function createNeverBrokenCircuitBreaker() {
473
814
  },
474
815
  };
475
816
  }
817
+ const CB_DEFAULT_OPTIONS = new InjectionToken('MMSTACK_CIRCUIT_BREAKER_DEFAULT_OPTIONS');
818
+ function provideCircuitBreakerDefaultOptions(options) {
819
+ return {
820
+ provide: CB_DEFAULT_OPTIONS,
821
+ useValue: {
822
+ ...DEFAULT_OPTIONS,
823
+ ...options,
824
+ },
825
+ };
826
+ }
827
+ function injectCircuitBreakerOptions(injector = inject(Injector)) {
828
+ return injector.get(CB_DEFAULT_OPTIONS, DEFAULT_OPTIONS, {
829
+ optional: true,
830
+ });
831
+ }
476
832
  /**
477
833
  * Creates a circuit breaker instance.
478
834
  *
@@ -496,12 +852,16 @@ function createNeverBrokenCircuitBreaker() {
496
852
  * const resource1 = queryResource(..., { circuitBreaker: sharedBreaker });
497
853
  * const resource2 = mutationResource(..., { circuitBreaker: sharedBreaker });
498
854
  */
499
- function createCircuitBreaker(opt) {
855
+ function createCircuitBreaker(opt, injector) {
500
856
  if (opt === false)
501
857
  return createNeverBrokenCircuitBreaker();
502
858
  if (typeof opt === 'object' && 'isClosed' in opt)
503
859
  return opt;
504
- return internalCeateCircuitBreaker(opt?.treshold, opt?.timeout);
860
+ const { treshold, timeout, shouldFail, shouldFailForever } = {
861
+ ...injectCircuitBreakerOptions(injector),
862
+ ...opt,
863
+ };
864
+ return internalCeateCircuitBreaker(treshold, timeout, shouldFail, shouldFailForever);
505
865
  }
506
866
 
507
867
  // Heavily inspired by: https://dev.to/kasual1/request-deduplication-in-angular-3pd8
@@ -571,6 +931,82 @@ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OP
571
931
  };
572
932
  }
573
933
 
934
+ /**
935
+ * Checks if `value` is a plain JavaScript object (e.g., `{}` or `new Object()`).
936
+ * Distinguishes from arrays, null, and class instances. Acts as a type predicate,
937
+ * narrowing `value` to `UnknownObject` if `true`.
938
+ *
939
+ * @param value The value to check.
940
+ * @returns {value is UnknownObject} `true` if `value` is a plain object, otherwise `false`.
941
+ * @example
942
+ * isPlainObject({}) // => true
943
+ * isPlainObject([]) // => false
944
+ * isPlainObject(null) // => false
945
+ * isPlainObject(new Date()) // => false
946
+ */
947
+ function isPlainObject(value) {
948
+ if (value === null || typeof value !== 'object')
949
+ return false;
950
+ const proto = Object.getPrototypeOf(value);
951
+ if (proto === null)
952
+ return false; // remove Object.create(null);
953
+ return proto === Object.prototype;
954
+ }
955
+ /**
956
+ * Internal helper to generate a stable JSON string from an array.
957
+ * Sorts keys of plain objects within the array alphabetically before serialization
958
+ * to ensure hash stability regardless of key order.
959
+ *
960
+ * @param queryKey The array of values to serialize.
961
+ * @returns A stable JSON string representation.
962
+ * @internal
963
+ */
964
+ function hashKey(queryKey) {
965
+ return JSON.stringify(queryKey, (_, val) => isPlainObject(val)
966
+ ? Object.keys(val)
967
+ .toSorted()
968
+ .reduce((result, key) => {
969
+ result[key] = val[key];
970
+ return result;
971
+ }, {})
972
+ : val);
973
+ }
974
+ /**
975
+ * Generates a stable, unique string hash from one or more arguments.
976
+ * Useful for creating cache keys or identifiers where object key order shouldn't matter.
977
+ *
978
+ * How it works:
979
+ * - Plain objects within the arguments have their keys sorted alphabetically before hashing.
980
+ * This ensures that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
981
+ * - Uses `JSON.stringify` internally with custom sorting for plain objects via `hashKey`.
982
+ * - Non-plain objects (arrays, Dates, etc.) and primitives are serialized naturally.
983
+ *
984
+ * @param {...unknown} args Values to include in the hash.
985
+ * @returns A stable string hash representing the input arguments.
986
+ * @example
987
+ * const userQuery = (id: number) => ['user', { id, timestamp: Date.now() }];
988
+ *
989
+ * const obj1 = { a: 1, b: 2 };
990
+ * const obj2 = { b: 2, a: 1 }; // Same keys/values, different order
991
+ *
992
+ * hash('posts', 10);
993
+ * // => '["posts",10]'
994
+ *
995
+ * hash('config', obj1);
996
+ * // => '["config",{"a":1,"b":2}]'
997
+ *
998
+ * hash('config', obj2);
999
+ * // => '["config",{"a":1,"b":2}]' (Same as above due to key sorting)
1000
+ *
1001
+ * hash(['todos', { status: 'done', owner: obj1 }]);
1002
+ * // => '[["todos",{"owner":{"a":1,"b":2},"status":"done"}]]'
1003
+ *
1004
+ * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
1005
+ * // hash('a', undefined, function() {}) => '["a",null,null]'
1006
+ */
1007
+ function hash(...args) {
1008
+ return hashKey(args);
1009
+ }
574
1010
  function equalTransferCache(a, b) {
575
1011
  if (!a && !b)
576
1012
  return true;
@@ -591,16 +1027,62 @@ function equalTransferCache(a, b) {
591
1027
  const aSet = new Set(a.includeHeaders ?? []);
592
1028
  return b.includeHeaders.every((header) => aSet.has(header));
593
1029
  }
1030
+ function equalParamArray(a, b) {
1031
+ if (!a && !b)
1032
+ return true;
1033
+ if (!a || !b)
1034
+ return false;
1035
+ if (a.length !== b.length)
1036
+ return false;
1037
+ return a.every((value) => b.includes(value));
1038
+ }
1039
+ function headersToObject(headerClass) {
1040
+ const headers = {};
1041
+ headerClass.keys().forEach((key) => {
1042
+ const value = headerClass.getAll(key);
1043
+ if (value === null)
1044
+ return;
1045
+ if (value.length === 1) {
1046
+ headers[key] = value[0];
1047
+ }
1048
+ else {
1049
+ headers[key] = value;
1050
+ }
1051
+ });
1052
+ return headers;
1053
+ }
1054
+ function paramToObject(paramsClass) {
1055
+ const params = {};
1056
+ paramsClass.keys().forEach((key) => {
1057
+ const value = paramsClass.getAll(key);
1058
+ if (value === null)
1059
+ return;
1060
+ if (value.length === 1) {
1061
+ params[key] = value[0];
1062
+ }
1063
+ else {
1064
+ params[key] = value;
1065
+ }
1066
+ });
1067
+ return params;
1068
+ }
594
1069
  function equalParams(a, b) {
595
1070
  if (!a && !b)
596
1071
  return true;
597
1072
  if (!a || !b)
598
1073
  return false;
599
- const aKeys = keys(a);
600
- const bKeys = keys(b);
1074
+ const aObj = a instanceof HttpParams ? paramToObject(a) : a;
1075
+ const bObj = b instanceof HttpParams ? paramToObject(b) : b;
1076
+ const aKeys = Object.keys(aObj);
1077
+ const bKeys = Object.keys(bObj);
601
1078
  if (aKeys.length !== bKeys.length)
602
1079
  return false;
603
- return aKeys.every((key) => a[key] === b[key]);
1080
+ return aKeys.every((key) => {
1081
+ if (Array.isArray(aObj[key]) || Array.isArray(bObj[key])) {
1082
+ return equalParamArray(Array.isArray(aObj[key]) ? aObj[key] : [aObj[key]], Array.isArray(bObj[key]) ? bObj[key] : [bObj[key]]);
1083
+ }
1084
+ return aObj[key] === bObj[key];
1085
+ });
604
1086
  }
605
1087
  function equalBody(a, b) {
606
1088
  if (!a && !b)
@@ -614,22 +1096,44 @@ function equalHeaders(a, b) {
614
1096
  return true;
615
1097
  if (!a || !b)
616
1098
  return false;
617
- const aKeys = keys(a);
618
- const bKeys = keys(b);
1099
+ const aObj = a instanceof HttpHeaders ? headersToObject(a) : a;
1100
+ const bObj = b instanceof HttpHeaders ? headersToObject(b) : b;
1101
+ const aKeys = Object.keys(aObj);
1102
+ const bKeys = Object.keys(bObj);
619
1103
  if (aKeys.length !== bKeys.length)
620
1104
  return false;
621
- return aKeys.every((key) => a[key] === b[key]);
1105
+ return aKeys.every((key) => {
1106
+ if (Array.isArray(aObj[key]) || Array.isArray(bObj[key])) {
1107
+ return equalParamArray(Array.isArray(aObj[key]) ? aObj[key] : [aObj[key]], Array.isArray(bObj[key]) ? bObj[key] : [bObj[key]]);
1108
+ }
1109
+ return aObj[key] === bObj[key];
1110
+ });
1111
+ }
1112
+ function toHttpContextEntries(ctx) {
1113
+ if (!ctx)
1114
+ return [];
1115
+ if (ctx instanceof HttpContext) {
1116
+ const tokens = Array.from(ctx.keys());
1117
+ return tokens.map((key) => [key.toString(), ctx.get(key)]);
1118
+ }
1119
+ if (typeof ctx === 'object') {
1120
+ return Object.entries(ctx);
1121
+ }
1122
+ return [];
622
1123
  }
623
1124
  function equalContext(a, b) {
624
1125
  if (!a && !b)
625
1126
  return true;
626
1127
  if (!a || !b)
627
1128
  return false;
628
- const aKeys = keys(a);
629
- const bKeys = keys(b);
630
- if (aKeys.length !== bKeys.length)
1129
+ const aEntries = toHttpContextEntries(a);
1130
+ const bEntries = toHttpContextEntries(b);
1131
+ if (aEntries.length !== bEntries.length)
631
1132
  return false;
632
- return aKeys.every((key) => a[key] === b[key]);
1133
+ if (aEntries.length === 0)
1134
+ return true;
1135
+ const bMap = new Map(bEntries);
1136
+ return aEntries.every(([key, value]) => value === bMap.get(key));
633
1137
  }
634
1138
  function createEqualRequest(equalResult) {
635
1139
  const eqb = equalResult ?? equalBody;
@@ -672,38 +1176,33 @@ function hasSlowConnection() {
672
1176
  return false;
673
1177
  }
674
1178
 
675
- function presist(value, usePrevious, equal) {
1179
+ function persist(src, equal) {
676
1180
  // linkedSignal allows us to access previous source value
677
1181
  const persisted = linkedSignal({
678
- source: () => {
679
- return {
680
- value: value(),
681
- usePrevious: usePrevious(),
682
- };
683
- },
684
- computation: (source, prev) => {
685
- if (source.usePrevious && prev)
1182
+ source: () => src(),
1183
+ computation: (next, prev) => {
1184
+ if (next === undefined && prev !== undefined)
686
1185
  return prev.value;
687
- return source.value;
1186
+ return next;
688
1187
  },
689
1188
  equal,
690
1189
  });
691
1190
  // if original value was WritableSignal then override linkedSignal methods to original...angular uses linkedSignal under the hood in ResourceImpl, this applies to that.
692
- if ('set' in value) {
693
- persisted.set = value.set;
694
- persisted.update = value.update;
695
- persisted.asReadonly = value.asReadonly;
1191
+ if ('set' in src) {
1192
+ persisted.set = src.set;
1193
+ persisted.update = src.update;
1194
+ persisted.asReadonly = src.asReadonly;
696
1195
  }
697
1196
  return persisted;
698
1197
  }
699
- function persistResourceValues(resource, persist = false, equal) {
700
- if (!persist)
1198
+ function persistResourceValues(resource, shouldPersist = false, equal) {
1199
+ if (!shouldPersist)
701
1200
  return resource;
702
1201
  return {
703
1202
  ...resource,
704
- statusCode: presist(resource.statusCode, resource.isLoading),
705
- headers: presist(resource.headers, resource.isLoading),
706
- value: presist(resource.value, resource.isLoading, equal),
1203
+ statusCode: persist(resource.statusCode),
1204
+ headers: persist(resource.headers),
1205
+ value: persist(resource.value, equal),
707
1206
  };
708
1207
  }
709
1208
 
@@ -770,11 +1269,44 @@ function retryOnError(res, opt) {
770
1269
  };
771
1270
  }
772
1271
 
1272
+ class ResourceSensors {
1273
+ networkStatus = sensor('networkStatus');
1274
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: ResourceSensors, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1275
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: ResourceSensors, providedIn: 'root' });
1276
+ }
1277
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: ResourceSensors, decorators: [{
1278
+ type: Injectable,
1279
+ args: [{
1280
+ providedIn: 'root',
1281
+ }]
1282
+ }] });
1283
+ function injectNetworkStatus() {
1284
+ return inject(ResourceSensors).networkStatus;
1285
+ }
1286
+
1287
+ function toResourceObject(res) {
1288
+ return {
1289
+ asReadonly: () => res.asReadonly(),
1290
+ destroy: () => res.destroy(),
1291
+ error: res.error,
1292
+ headers: res.headers,
1293
+ isLoading: res.isLoading,
1294
+ progress: res.progress,
1295
+ status: res.status,
1296
+ statusCode: res.statusCode,
1297
+ value: res.value,
1298
+ reload: () => res.reload(),
1299
+ hasValue: (() => res.hasValue()),
1300
+ set: (v) => res.set(v),
1301
+ update: (v) => res.update(v),
1302
+ };
1303
+ }
1304
+
773
1305
  function normalizeParams(params) {
774
1306
  if (params instanceof HttpParams)
775
1307
  return params.toString();
776
1308
  const paramMap = new Map();
777
- for (const [key, value] of entries(params)) {
1309
+ for (const [key, value] of Object.entries(params)) {
778
1310
  if (Array.isArray(value)) {
779
1311
  paramMap.set(key, value.map(encodeURIComponent).join(','));
780
1312
  }
@@ -783,6 +1315,7 @@ function normalizeParams(params) {
783
1315
  }
784
1316
  }
785
1317
  return Array.from(paramMap.entries())
1318
+ .sort(([a], [b]) => a.localeCompare(b))
786
1319
  .map(([key, value]) => `${key}=${value}`)
787
1320
  .join('&');
788
1321
  }
@@ -799,13 +1332,26 @@ function queryResource(request, options) {
799
1332
  : inject(DestroyRef);
800
1333
  const cb = createCircuitBreaker(options?.circuitBreaker === true
801
1334
  ? undefined
802
- : (options?.circuitBreaker ?? false));
1335
+ : (options?.circuitBreaker ?? false), options?.injector);
1336
+ const networkAvailable = injectNetworkStatus();
1337
+ const eq = options?.triggerOnSameRequest
1338
+ ? undefined
1339
+ : createEqualRequest(options?.equal);
803
1340
  const stableRequest = computed(() => {
804
- if (cb.isClosed())
1341
+ if (!networkAvailable() || cb.isOpen())
1342
+ return undefined;
1343
+ const req = request();
1344
+ if (!req)
805
1345
  return undefined;
806
- return request() ?? undefined;
1346
+ if (typeof req === 'string')
1347
+ return { method: 'GET', url: req };
1348
+ return req;
807
1349
  }, {
808
- equal: createEqualRequest(options?.equal),
1350
+ equal: (a, b) => {
1351
+ if (eq)
1352
+ return eq(a, b);
1353
+ return a === b;
1354
+ },
809
1355
  });
810
1356
  const hashFn = typeof options?.cache === 'object'
811
1357
  ? (options.cache.hash ?? urlWithParams)
@@ -818,6 +1364,11 @@ function queryResource(request, options) {
818
1364
  return null;
819
1365
  return hashFn(r);
820
1366
  });
1367
+ const bustBrowserCache = typeof options?.cache === 'object' &&
1368
+ options.cache.bustBrowserCache === true;
1369
+ const ignoreCacheControl = typeof options?.cache === 'object' &&
1370
+ options.cache.ignoreCacheControl === true;
1371
+ const persist = typeof options?.cache === 'object' && options.cache.persist === true;
821
1372
  const cachedRequest = options?.cache
822
1373
  ? computed(() => {
823
1374
  const r = stableRequest();
@@ -829,31 +1380,39 @@ function queryResource(request, options) {
829
1380
  staleTime,
830
1381
  ttl,
831
1382
  key: cacheKey() ?? hashFn(r),
1383
+ bustBrowserCache,
1384
+ ignoreCacheControl,
1385
+ persist,
832
1386
  }),
833
1387
  };
834
1388
  })
835
1389
  : stableRequest;
836
- let resource = httpResource(cachedRequest, {
1390
+ let resource = toResourceObject(httpResource(cachedRequest, {
837
1391
  ...options,
838
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
839
1392
  parse: options?.parse, // Not my favorite thing to do, but here it is completely safe.
840
- });
1393
+ }));
1394
+ resource = catchValueError(resource, options?.defaultValue);
841
1395
  // get full HttpResonse from Cache
842
- const cachedEvent = cache.get(cacheKey);
843
- const parse = options?.parse ?? ((val) => val);
844
- const actualCacheValue = computed(() => {
845
- const ce = cachedEvent();
846
- if (!ce || !(ce.value instanceof HttpResponse))
847
- return;
848
- return parse(ce.value.body);
849
- });
850
- // retains last cache value after it is invalidated for lifetime of resource
851
- const cachedValue = linkedSignal({
852
- source: () => actualCacheValue(),
853
- computation: (source, prev) => {
854
- if (!source && prev)
1396
+ const cachedEvent = cache.getEntryOrKey(cacheKey);
1397
+ const cacheEntry = linkedSignal({
1398
+ source: () => cachedEvent(),
1399
+ computation: (entry, prev) => {
1400
+ if (!entry)
1401
+ return null;
1402
+ if (typeof entry === 'string' &&
1403
+ prev &&
1404
+ prev.value !== null &&
1405
+ prev.value.key === entry) {
855
1406
  return prev.value;
856
- return source;
1407
+ }
1408
+ if (typeof entry === 'string')
1409
+ return { key: entry, value: null };
1410
+ if (!(entry.value instanceof HttpResponse))
1411
+ return { key: entry.key, value: null };
1412
+ return {
1413
+ value: entry.value.body,
1414
+ key: entry.key,
1415
+ };
857
1416
  },
858
1417
  });
859
1418
  resource = refresh(resource, destroyRef, options?.refresh);
@@ -861,7 +1420,8 @@ function queryResource(request, options) {
861
1420
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
862
1421
  const value = options?.cache
863
1422
  ? toWritable(computed(() => {
864
- return cachedValue() ?? resource.value();
1423
+ resource.value();
1424
+ return cacheEntry()?.value ?? resource.value();
865
1425
  }), resource.value.set, resource.value.update)
866
1426
  : resource.value;
867
1427
  const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
@@ -882,7 +1442,7 @@ function queryResource(request, options) {
882
1442
  const cbEffectRef = effect(() => {
883
1443
  const status = resource.status();
884
1444
  if (status === ResourceStatus.Error)
885
- cb.fail();
1445
+ cb.fail(untracked(resource.error));
886
1446
  else if (status === ResourceStatus.Resolved)
887
1447
  cb.success();
888
1448
  });
@@ -894,7 +1454,7 @@ function queryResource(request, options) {
894
1454
  body: value,
895
1455
  status: 200,
896
1456
  statusText: 'OK',
897
- }));
1457
+ }), staleTime, ttl, persist);
898
1458
  };
899
1459
  const update = (updater) => {
900
1460
  set(updater(untracked(resource.value)));
@@ -907,7 +1467,9 @@ function queryResource(request, options) {
907
1467
  value,
908
1468
  set,
909
1469
  update,
910
- disabled: computed(() => cb.isClosed() || stableRequest() === undefined),
1470
+ statusCode: linkedSignal(resource.statusCode),
1471
+ headers: linkedSignal(resource.headers),
1472
+ disabled: computed(() => cb.isOpen() || stableRequest() === undefined),
911
1473
  reload: () => {
912
1474
  cb.halfOpen(); // open the circuit for manual reload
913
1475
  return resource.reload();
@@ -920,16 +1482,35 @@ function queryResource(request, options) {
920
1482
  prefetch: async (partial) => {
921
1483
  if (!options?.cache || hasSlowConnection())
922
1484
  return Promise.resolve();
923
- const request = untracked(cachedRequest);
924
- if (!request)
925
- return Promise.resolve();
1485
+ const request = untracked(stableRequest);
1486
+ const partialReq = typeof partial === 'string' ? { method: 'GET', url: partial } : partial;
926
1487
  const prefetchRequest = {
927
1488
  ...request,
928
- ...partial,
1489
+ ...partialReq,
929
1490
  };
1491
+ if (!prefetchRequest.url)
1492
+ return Promise.resolve();
1493
+ const key = hashFn({
1494
+ ...prefetchRequest,
1495
+ url: prefetchRequest.url ?? '',
1496
+ });
1497
+ const found = cache.getUntracked(key);
1498
+ if (found && !found.isStale)
1499
+ return Promise.resolve();
930
1500
  try {
931
1501
  await firstValueFrom(client.request(prefetchRequest.method ?? 'GET', prefetchRequest.url, {
932
1502
  ...prefetchRequest,
1503
+ context: setCacheContext(prefetchRequest.context, {
1504
+ staleTime,
1505
+ ttl,
1506
+ key: hashFn({
1507
+ ...prefetchRequest,
1508
+ url: prefetchRequest.url ?? '',
1509
+ }),
1510
+ bustBrowserCache,
1511
+ ignoreCacheControl,
1512
+ persist,
1513
+ }),
933
1514
  headers: prefetchRequest.headers,
934
1515
  observe: 'response',
935
1516
  }));
@@ -944,6 +1525,46 @@ function queryResource(request, options) {
944
1525
  };
945
1526
  }
946
1527
 
1528
+ function manualQueryResource(request, options) {
1529
+ const trigger = signal({ epoch: 0 }, {
1530
+ equal: (a, b) => a.epoch === b.epoch,
1531
+ });
1532
+ const injector = options?.injector ?? inject(Injector);
1533
+ const req = computed(() => {
1534
+ const state = trigger();
1535
+ if (state.epoch === 0)
1536
+ return;
1537
+ if (state.override)
1538
+ return state.override;
1539
+ return untracked(request);
1540
+ }, {
1541
+ equal: () => false,
1542
+ });
1543
+ const resource = queryResource(req, options);
1544
+ return {
1545
+ ...resource,
1546
+ trigger: (override, injectorOverride) => {
1547
+ trigger.update((s) => ({
1548
+ epoch: s.epoch + 1,
1549
+ override,
1550
+ }));
1551
+ return new Promise((res, rej) => {
1552
+ const watcher = nestedEffect(() => {
1553
+ const status = resource.status();
1554
+ if (status === ResourceStatus.Resolved) {
1555
+ watcher.destroy();
1556
+ res(untracked(resource.value));
1557
+ }
1558
+ else if (status === ResourceStatus.Error) {
1559
+ watcher.destroy();
1560
+ rej(untracked(resource.error));
1561
+ }
1562
+ }, { injector: injectorOverride ?? injector });
1563
+ });
1564
+ },
1565
+ };
1566
+ }
1567
+
947
1568
  /**
948
1569
  * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
949
1570
  * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
@@ -951,96 +1572,124 @@ function queryResource(request, options) {
951
1572
  * managing the mutation lifecycle (pending, error, success) and provides callbacks for handling
952
1573
  * these states.
953
1574
  *
954
- * @param request A function that returns the base `HttpResourceRequest` to be made. This
955
- * function is called reactively. Unlike `queryResource`, the `body` property
956
- * of the request is provided when `mutate` is called, *not* here. If the
957
- * function returns `undefined`, the mutation is considered "disabled." All properties,
958
- * except the body, can be set here.
1575
+ * @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.
959
1576
  * @param options Configuration options for the mutation resource. This includes callbacks
960
1577
  * for `onMutate`, `onError`, `onSuccess`, and `onSettled`.
961
1578
  * @typeParam TResult - The type of the expected result from the mutation.
962
1579
  * @typeParam TRaw - The raw response type from the HTTP request (defaults to TResult).
1580
+ * @typeParam TMutation - The type of the mutation value (the request body).
1581
+ * @typeParam TICTX - The type of the initial context value passed to `onMutate`.
963
1582
  * @typeParam TCTX - The type of the context value returned by `onMutate`.
1583
+ * @typeParam TMethod - The HTTP method to be used for the mutation (defaults to `HttpResourceRequest['method']`).
964
1584
  * @returns A `MutationResourceRef` instance, which provides methods for triggering the mutation
965
1585
  * and observing its status.
966
1586
  */
967
1587
  function mutationResource(request, options = {}) {
968
1588
  const { onMutate, onError, onSuccess, onSettled, equal, ...rest } = options;
969
1589
  const requestEqual = createEqualRequest(equal);
970
- const baseRequest = computed(() => request() ?? undefined, {
971
- equal: requestEqual,
972
- });
973
- const nextRequest = signal(null, {
1590
+ const eq = equal ?? Object.is;
1591
+ const next = signal(null, {
974
1592
  equal: (a, b) => {
975
1593
  if (!a && !b)
976
1594
  return true;
977
1595
  if (!a || !b)
978
1596
  return false;
979
- return requestEqual(a, b);
1597
+ return eq(a, b);
980
1598
  },
981
1599
  });
1600
+ const queue = signal([]);
1601
+ let ctx = undefined;
1602
+ const queueRef = effect(() => {
1603
+ const nextInQueue = queue().at(0);
1604
+ if (!nextInQueue || next() !== null)
1605
+ return;
1606
+ queue.update((q) => q.slice(1));
1607
+ const [value, ictx] = nextInQueue;
1608
+ ctx = onMutate?.(value, ictx);
1609
+ next.set(value);
1610
+ });
982
1611
  const req = computed(() => {
983
- const nr = nextRequest();
1612
+ const nr = next();
984
1613
  if (!nr)
985
1614
  return;
986
- const base = baseRequest();
987
- const url = nr.url ?? base?.url;
988
- if (!url)
1615
+ return request(nr) ?? undefined;
1616
+ }, {
1617
+ equal: requestEqual,
1618
+ });
1619
+ const lastValue = linkedSignal({
1620
+ source: next,
1621
+ computation: (next, prev) => {
1622
+ if (next === null && !!prev)
1623
+ return prev.value;
1624
+ return next;
1625
+ },
1626
+ });
1627
+ const lastValueRequest = computed(() => {
1628
+ const nr = lastValue();
1629
+ if (!nr)
989
1630
  return;
990
- const method = nr.method ?? base?.method;
991
- return {
992
- ...base,
993
- ...nr,
994
- url,
995
- method,
996
- };
1631
+ return request(nr) ?? undefined;
1632
+ }, {
1633
+ equal: requestEqual,
997
1634
  });
1635
+ const cb = createCircuitBreaker(options?.circuitBreaker === true
1636
+ ? undefined
1637
+ : (options?.circuitBreaker ?? false), options?.injector);
998
1638
  const resource = queryResource(req, {
999
1639
  ...rest,
1640
+ circuitBreaker: cb,
1000
1641
  defaultValue: null, // doesnt matter since .value is not accessible
1001
1642
  });
1002
- let ctx = undefined;
1003
1643
  const destroyRef = options.injector
1004
1644
  ? options.injector.get(DestroyRef)
1005
1645
  : inject(DestroyRef);
1006
1646
  const error$ = toObservable(resource.error);
1007
- const value$ = toObservable(resource.value);
1647
+ const value$ = toObservable(resource.value).pipe(catchError(() => of(null)));
1008
1648
  const statusSub = toObservable(resource.status)
1009
1649
  .pipe(combineLatestWith(error$, value$), map(([status, error, value]) => {
1010
1650
  if (status === ResourceStatus.Error && error) {
1011
1651
  return {
1012
- status: ResourceStatus.Error,
1652
+ status: 'error',
1013
1653
  error,
1014
1654
  };
1015
1655
  }
1016
1656
  if (status === ResourceStatus.Resolved && value !== null) {
1017
1657
  return {
1018
- status: ResourceStatus.Resolved,
1658
+ status: 'resolved',
1019
1659
  value,
1020
1660
  };
1021
1661
  }
1022
1662
  return null;
1023
1663
  }), filter((v) => v !== null), takeUntilDestroyed(destroyRef))
1024
1664
  .subscribe((result) => {
1025
- if (result.status === ResourceStatus.Error)
1665
+ if (result.status === 'error')
1026
1666
  onError?.(result.error, ctx);
1027
1667
  else
1028
1668
  onSuccess?.(result.value, ctx);
1029
1669
  onSettled?.(ctx);
1030
1670
  ctx = undefined;
1031
- nextRequest.set(null);
1671
+ next.set(null);
1032
1672
  });
1673
+ const shouldQueue = options.queue ?? false;
1033
1674
  return {
1034
1675
  ...resource,
1035
1676
  destroy: () => {
1036
1677
  statusSub.unsubscribe();
1037
1678
  resource.destroy();
1679
+ queueRef.destroy();
1038
1680
  },
1039
1681
  mutate: (value, ictx) => {
1040
- ctx = onMutate?.(value.body, ictx);
1041
- nextRequest.set(value);
1682
+ if (shouldQueue) {
1683
+ return queue.update((q) => [...q, [value, ictx]]);
1684
+ }
1685
+ else {
1686
+ ctx = onMutate?.(value, ictx);
1687
+ next.set(value);
1688
+ }
1042
1689
  },
1043
- current: nextRequest,
1690
+ current: next,
1691
+ // redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
1692
+ disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
1044
1693
  };
1045
1694
  }
1046
1695
 
@@ -1048,5 +1697,5 @@ function mutationResource(request, options = {}) {
1048
1697
  * Generated bundle index. Do not edit.
1049
1698
  */
1050
1699
 
1051
- export { Cache, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, mutationResource, noDedupe, provideQueryCache, queryResource };
1700
+ export { Cache, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideQueryCache, queryResource };
1052
1701
  //# sourceMappingURL=mmstack-resource.mjs.map