@mmstack/resource 20.2.8 → 20.2.9

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