@mmstack/resource 20.2.8 → 20.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,14 @@ export const appConfig: ApplicationConfig = {
37
37
  providers: [
38
38
  // ..other providers
39
39
  provideQueryCache(),
40
+
41
+ // --- Example of a more advanced setup ---
42
+ // provideQueryCache({
43
+ // persist: true, // Enable IndexedDB persistence
44
+ // version: 1, // Version for the cache schema
45
+ // syncTabs: true // enable BroadcastChannel
46
+ // }),
47
+
40
48
  provideHttpClient(withInterceptors([createCacheInterceptor(), createDedupeRequestsInterceptor()])),
41
49
  ],
42
50
  };
@@ -1,4 +1,4 @@
1
- import { computed, untracked, InjectionToken, isDevMode, inject, signal, effect, Injector, linkedSignal, DestroyRef } from '@angular/core';
1
+ import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, DestroyRef } from '@angular/core';
2
2
  import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
3
3
  import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
4
4
  import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext, HttpParams, httpResource, HttpClient } from '@angular/common/http';
@@ -6,6 +6,93 @@ import { mutable, toWritable } from '@mmstack/primitives';
6
6
  import { v7 } from 'uuid';
7
7
  import { keys, hash, entries } from '@mmstack/object';
8
8
 
9
+ function createNoopDB() {
10
+ return {
11
+ getAll: async () => [],
12
+ store: async () => {
13
+ // noop
14
+ },
15
+ remove: async () => {
16
+ // noop
17
+ },
18
+ };
19
+ }
20
+ function toCacheDB(db, storeName) {
21
+ const getAll = async () => {
22
+ const now = Date.now();
23
+ return new Promise((res, rej) => {
24
+ const transaction = db.transaction(storeName, 'readonly');
25
+ const store = transaction.objectStore(storeName);
26
+ const request = store.getAll();
27
+ request.onsuccess = () => res(request.result);
28
+ request.onerror = () => rej(request.error);
29
+ })
30
+ .then((entries) => entries.filter((e) => e.expiresAt > now))
31
+ .catch((err) => {
32
+ if (isDevMode())
33
+ console.error('Error getting all items from cache DB:', err);
34
+ return [];
35
+ });
36
+ };
37
+ const store = (value) => {
38
+ return new Promise((res, rej) => {
39
+ const transaction = db.transaction(storeName, 'readwrite');
40
+ const store = transaction.objectStore(storeName);
41
+ store.put(value);
42
+ transaction.oncomplete = () => res();
43
+ transaction.onerror = () => rej(transaction.error);
44
+ }).catch((err) => {
45
+ if (isDevMode())
46
+ console.error('Error storing item in cache DB:', err);
47
+ });
48
+ };
49
+ const remove = (key) => {
50
+ return new Promise((res, rej) => {
51
+ const transaction = db.transaction(storeName, 'readwrite');
52
+ const store = transaction.objectStore(storeName);
53
+ store.delete(key);
54
+ transaction.oncomplete = () => res();
55
+ transaction.onerror = () => rej(transaction.error);
56
+ }).catch((err) => {
57
+ if (isDevMode())
58
+ console.error('Error removing item from cache DB:', err);
59
+ });
60
+ };
61
+ return {
62
+ getAll,
63
+ store,
64
+ remove,
65
+ };
66
+ }
67
+ function createSingleStoreDB(name, getStoreName, version = 1) {
68
+ const storeName = getStoreName(version);
69
+ if (!globalThis.indexedDB)
70
+ return Promise.resolve(createNoopDB());
71
+ return new Promise((res, rej) => {
72
+ if (version < 1)
73
+ rej(new Error('Version must be 1 or greater'));
74
+ const req = indexedDB.open(name, version);
75
+ req.onupgradeneeded = (event) => {
76
+ const db = req.result;
77
+ const oldVersion = event.oldVersion;
78
+ db.createObjectStore(storeName, { keyPath: 'key' });
79
+ if (oldVersion > 0) {
80
+ db.deleteObjectStore(getStoreName(oldVersion));
81
+ }
82
+ };
83
+ req.onerror = () => {
84
+ rej(req.error);
85
+ };
86
+ req.onsuccess = () => res(req.result);
87
+ })
88
+ .then((db) => toCacheDB(db, storeName))
89
+ .catch((err) => {
90
+ if (isDevMode())
91
+ console.error('Error creating query DB:', err);
92
+ return createNoopDB();
93
+ });
94
+ }
95
+
9
96
  function isSyncMessage(msg) {
10
97
  return (typeof msg === 'object' &&
11
98
  msg !== null &&
@@ -27,8 +114,10 @@ const DEFAULT_CLEANUP_OPT = {
27
114
  class Cache {
28
115
  ttl;
29
116
  staleTime;
117
+ db;
30
118
  internal = mutable(new Map());
31
119
  cleanupOpt;
120
+ id = v7();
32
121
  /**
33
122
  * Destroys the cache instance, cleaning up any resources used by the cache.
34
123
  * This method is called automatically when the cache instance is garbage collected.
@@ -52,9 +141,10 @@ class Cache {
52
141
  type: 'lru',
53
142
  maxSize: 1000,
54
143
  checkInterval: ONE_HOUR,
55
- }, syncTabs) {
144
+ }, syncTabs, db = Promise.resolve(createNoopDB())) {
56
145
  this.ttl = ttl;
57
146
  this.staleTime = staleTime;
147
+ this.db = db;
58
148
  this.cleanupOpt = {
59
149
  ...DEFAULT_CLEANUP_OPT,
60
150
  ...cleanupOpt,
@@ -65,7 +155,6 @@ class Cache {
65
155
  const cleanupInterval = setInterval(() => {
66
156
  this.cleanup();
67
157
  }, cleanupOpt.checkInterval);
68
- const cacheId = v7();
69
158
  let destroySyncTabs = () => {
70
159
  // noop
71
160
  };
@@ -76,7 +165,7 @@ class Cache {
76
165
  return channel.postMessage({
77
166
  action: 'invalidate',
78
167
  entry: { key: msg.entry.key },
79
- cacheId,
168
+ cacheId: this.id,
80
169
  type: 'cache-sync-message',
81
170
  });
82
171
  return channel.postMessage({
@@ -85,7 +174,7 @@ class Cache {
85
174
  ...msg.entry,
86
175
  value: syncTabs.serialize(msg.entry.value),
87
176
  },
88
- cacheId,
177
+ cacheId: this.id,
89
178
  type: 'cache-sync-message',
90
179
  });
91
180
  };
@@ -93,13 +182,13 @@ class Cache {
93
182
  const msg = event.data;
94
183
  if (!isSyncMessage(msg))
95
184
  return;
96
- if (msg.cacheId === cacheId)
185
+ if (msg.cacheId === this.id)
97
186
  return; // ignore messages from this cache
98
187
  if (msg.action === 'store') {
99
188
  const value = syncTabs.deserialize(msg.entry.value);
100
189
  if (value === null)
101
190
  return;
102
- this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true);
191
+ this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
103
192
  }
104
193
  else if (msg.action === 'invalidate') {
105
194
  this.invalidateInternal(msg.entry.key, true);
@@ -117,14 +206,31 @@ class Cache {
117
206
  clearInterval(cleanupInterval);
118
207
  destroySyncTabs();
119
208
  };
209
+ this.db
210
+ .then(async (db) => {
211
+ if (destroyed)
212
+ return [];
213
+ return db.getAll();
214
+ })
215
+ .then((entries) => {
216
+ if (destroyed)
217
+ return;
218
+ // load entries into the cache
219
+ const current = untracked(this.internal);
220
+ entries.forEach((entry) => {
221
+ if (current.has(entry.key))
222
+ return;
223
+ this.storeInternal(entry.key, entry.value, entry.stale - entry.updated, entry.expiresAt - entry.updated, true);
224
+ });
225
+ });
120
226
  this.destroy = destroy;
121
227
  // 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
122
228
  const registry = new FinalizationRegistry((id) => {
123
- if (id === cacheId) {
229
+ if (id === this.id) {
124
230
  destroy();
125
231
  }
126
232
  });
127
- registry.register(this, cacheId);
233
+ registry.register(this, this.id);
128
234
  }
129
235
  /** @internal */
130
236
  getInternal(key) {
@@ -183,10 +289,10 @@ class Cache {
183
289
  * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
184
290
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
185
291
  */
186
- store(key, value, staleTime = this.staleTime, ttl = this.ttl) {
187
- this.storeInternal(key, value, staleTime, ttl);
292
+ store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
293
+ this.storeInternal(key, value, staleTime, ttl, false, persist);
188
294
  }
189
- storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false) {
295
+ storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
190
296
  const entry = this.getUntracked(key);
191
297
  if (entry) {
192
298
  clearTimeout(entry.timeout); // stop invalidation
@@ -212,11 +318,14 @@ class Cache {
212
318
  });
213
319
  return map;
214
320
  });
215
- if (!fromSync)
321
+ if (!fromSync) {
322
+ if (persist)
323
+ this.db.then((db) => db.store(next));
216
324
  this.broadcast({
217
325
  action: 'store',
218
326
  entry: next,
219
327
  });
328
+ }
220
329
  }
221
330
  /**
222
331
  * Invalidates (removes) a cache entry.
@@ -235,8 +344,10 @@ class Cache {
235
344
  map.delete(key);
236
345
  return map;
237
346
  });
238
- if (!fromSync)
347
+ if (!fromSync) {
348
+ this.db.then((db) => db.remove(key));
239
349
  this.broadcast({ action: 'invalidate', entry: { key } });
350
+ }
240
351
  }
241
352
  /** @internal */
242
353
  cleanup() {
@@ -283,53 +394,80 @@ const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
283
394
  * };
284
395
  */
285
396
  function provideQueryCache(opt) {
286
- const syncTabsOpt = opt?.syncTabsId
397
+ const serialize = (value) => {
398
+ const headersRecord = {};
399
+ const headerKeys = value.headers.keys();
400
+ headerKeys.forEach((key) => {
401
+ const values = value.headers.getAll(key);
402
+ if (!values)
403
+ return;
404
+ headersRecord[key] = values;
405
+ });
406
+ return JSON.stringify({
407
+ body: value.body,
408
+ status: value.status,
409
+ statusText: value.statusText,
410
+ headers: headerKeys.length > 0 ? headersRecord : undefined,
411
+ url: value.url,
412
+ });
413
+ };
414
+ const deserialize = (value) => {
415
+ try {
416
+ const parsed = JSON.parse(value);
417
+ if (!parsed || typeof parsed !== 'object' || !('body' in parsed))
418
+ throw new Error('Invalid cache entry format');
419
+ const headers = parsed.headers
420
+ ? new HttpHeaders(parsed.headers)
421
+ : undefined;
422
+ return new HttpResponse({
423
+ body: parsed.body,
424
+ status: parsed.status,
425
+ statusText: parsed.statusText,
426
+ headers: headers,
427
+ url: parsed.url,
428
+ });
429
+ }
430
+ catch (err) {
431
+ if (isDevMode())
432
+ console.error('Failed to deserialize cache entry:', err);
433
+ return null;
434
+ }
435
+ };
436
+ const syncTabsOpt = opt?.syncTabs
287
437
  ? {
288
- id: opt.syncTabsId,
289
- serialize: (value) => {
290
- const headersRecord = {};
291
- const headerKeys = value.headers.keys();
292
- headerKeys.forEach((key) => {
293
- const values = value.headers.getAll(key);
294
- if (!values)
295
- return;
296
- headersRecord[key] = values;
297
- });
298
- return JSON.stringify({
299
- body: value.body,
300
- status: value.status,
301
- statusText: value.statusText,
302
- headers: headerKeys.length > 0 ? headersRecord : undefined,
303
- url: value.url,
304
- });
305
- },
306
- deserialize: (value) => {
307
- try {
308
- const parsed = JSON.parse(value);
309
- if (!parsed || typeof parsed !== 'object' || !('body' in parsed))
310
- throw new Error('Invalid cache entry format');
311
- const headers = parsed.headers
312
- ? new HttpHeaders(parsed.headers)
313
- : undefined;
314
- return new HttpResponse({
315
- body: parsed.body,
316
- status: parsed.status,
317
- statusText: parsed.statusText,
318
- headers: headers,
319
- url: parsed.url,
320
- });
321
- }
322
- catch (err) {
323
- if (isDevMode())
324
- console.error('Failed to deserialize cache entry:', err);
325
- return null;
326
- }
327
- },
438
+ id: 'mmstack-query-cache-sync',
439
+ serialize,
440
+ deserialize,
328
441
  }
329
442
  : undefined;
443
+ let db = opt?.persist === false
444
+ ? undefined
445
+ : createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
446
+ return {
447
+ getAll: () => {
448
+ return db.getAll().then((entries) => {
449
+ return entries
450
+ .map((entry) => {
451
+ const value = deserialize(entry.value);
452
+ if (value === null)
453
+ return null;
454
+ return {
455
+ ...entry,
456
+ value,
457
+ };
458
+ })
459
+ .filter((e) => e !== null);
460
+ });
461
+ },
462
+ store: (entry) => {
463
+ return db.store({ ...entry, value: serialize(entry.value) });
464
+ },
465
+ remove: db.remove,
466
+ };
467
+ });
330
468
  return {
331
469
  provide: CLIENT_CACHE_TOKEN,
332
- useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt),
470
+ useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db),
333
471
  };
334
472
  }
335
473
  class NoopCache extends Cache {
@@ -385,17 +523,7 @@ function setCacheContext(ctx = new HttpContext(), opt) {
385
523
  function getCacheContext(ctx) {
386
524
  return ctx.get(CACHE_CONTEXT);
387
525
  }
388
- function parseCacheControlHeader(req, ignoreCacheControl = false) {
389
- if (ignoreCacheControl) {
390
- return {
391
- noStore: false,
392
- noCache: false,
393
- mustRevalidate: false,
394
- immutable: false,
395
- maxAge: null,
396
- staleWhileRevalidate: null,
397
- };
398
- }
526
+ function parseCacheControlHeader(req) {
399
527
  const header = req.headers.get('Cache-Control');
400
528
  let sMaxAge = null;
401
529
  const directives = {
@@ -550,10 +678,12 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
550
678
  }
551
679
  return next(req).pipe(tap((event) => {
552
680
  if (event instanceof HttpResponse && event.ok) {
553
- const cacheControl = parseCacheControlHeader(event, opt.ignoreCacheControl);
554
- if (cacheControl.noStore)
681
+ const cacheControl = parseCacheControlHeader(event);
682
+ if (cacheControl.noStore && !opt.ignoreCacheControl)
555
683
  return;
556
- const { staleTime, ttl } = resolveTimings(cacheControl, opt.staleTime, opt.ttl);
684
+ const { staleTime, ttl } = opt.ignoreCacheControl
685
+ ? opt
686
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
557
687
  if (opt.ttl === 0)
558
688
  return; // no point
559
689
  const parsedResponse = opt.parse
@@ -565,7 +695,7 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
565
695
  url: event.url ?? undefined,
566
696
  })
567
697
  : event;
568
- cache.store(key, parsedResponse, staleTime, ttl);
698
+ cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
569
699
  }
570
700
  }), map((event) => {
571
701
  // handle 304 responses due to eTag/last-modified
@@ -1118,7 +1248,7 @@ function queryResource(request, options) {
1118
1248
  options.cache.bustBrowserCache === true;
1119
1249
  const ignoreCacheControl = typeof options?.cache === 'object' &&
1120
1250
  options.cache.ignoreCacheControl === true;
1121
- const parse = options?.parse ?? ((val) => val);
1251
+ const persist = typeof options?.cache === 'object' && options.cache.persist === true;
1122
1252
  const cachedRequest = options?.cache
1123
1253
  ? computed(() => {
1124
1254
  const r = stableRequest();
@@ -1132,6 +1262,7 @@ function queryResource(request, options) {
1132
1262
  key: cacheKey() ?? hashFn(r),
1133
1263
  bustBrowserCache,
1134
1264
  ignoreCacheControl,
1265
+ persist,
1135
1266
  }),
1136
1267
  };
1137
1268
  })
@@ -1203,7 +1334,7 @@ function queryResource(request, options) {
1203
1334
  body: value,
1204
1335
  status: 200,
1205
1336
  statusText: 'OK',
1206
- }));
1337
+ }), staleTime, ttl, persist);
1207
1338
  };
1208
1339
  const update = (updater) => {
1209
1340
  set(updater(untracked(resource.value)));
@@ -1262,6 +1393,7 @@ function queryResource(request, options) {
1262
1393
  }),
1263
1394
  bustBrowserCache,
1264
1395
  ignoreCacheControl,
1396
+ persist,
1265
1397
  }),
1266
1398
  headers: prefetchRequest.headers,
1267
1399
  observe: 'response',