@mmstack/resource 20.2.7 → 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,106 @@
1
- import { computed, untracked, InjectionToken, inject, isDevMode, 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
- import { HttpContextToken, HttpContext, HttpResponse, HttpParams, HttpHeaders, httpResource, HttpClient } from '@angular/common/http';
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
+
98
+ function isSyncMessage(msg) {
99
+ return (typeof msg === 'object' &&
100
+ msg !== null &&
101
+ 'type' in msg &&
102
+ msg.type === 'cache-sync-message');
103
+ }
9
104
  const ONE_DAY = 1000 * 60 * 60 * 24;
10
105
  const ONE_HOUR = 1000 * 60 * 60;
11
106
  const DEFAULT_CLEANUP_OPT = {
@@ -21,8 +116,18 @@ const DEFAULT_CLEANUP_OPT = {
21
116
  class Cache {
22
117
  ttl;
23
118
  staleTime;
119
+ db;
24
120
  internal = mutable(new Map());
25
121
  cleanupOpt;
122
+ id = v7();
123
+ /**
124
+ * Destroys the cache instance, cleaning up any resources used by the cache.
125
+ * This method is called automatically when the cache instance is garbage collected.
126
+ */
127
+ destroy;
128
+ broadcast = () => {
129
+ // noop
130
+ };
26
131
  /**
27
132
  * Creates a new `Cache` instance.
28
133
  *
@@ -31,14 +136,17 @@ class Cache {
31
136
  * stale but can still be used while revalidation occurs in the background. Defaults to 1 hour.
32
137
  * @param cleanupOpt - Options for configuring the cache cleanup strategy. Defaults to LRU with a
33
138
  * `maxSize` of 200 and a `checkInterval` of one hour.
139
+ * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
140
+ * Defaults to `undefined`, meaning no synchronization across tabs.
34
141
  */
35
142
  constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = {
36
143
  type: 'lru',
37
144
  maxSize: 1000,
38
145
  checkInterval: ONE_HOUR,
39
- }) {
146
+ }, syncTabs, db = Promise.resolve(createNoopDB())) {
40
147
  this.ttl = ttl;
41
148
  this.staleTime = staleTime;
149
+ this.db = db;
42
150
  this.cleanupOpt = {
43
151
  ...DEFAULT_CLEANUP_OPT,
44
152
  ...cleanupOpt,
@@ -49,14 +157,82 @@ class Cache {
49
157
  const cleanupInterval = setInterval(() => {
50
158
  this.cleanup();
51
159
  }, cleanupOpt.checkInterval);
52
- const destroyId = v7();
160
+ let destroySyncTabs = () => {
161
+ // noop
162
+ };
163
+ if (syncTabs) {
164
+ const channel = new BroadcastChannel(syncTabs.id);
165
+ this.broadcast = (msg) => {
166
+ if (msg.action === 'invalidate')
167
+ return channel.postMessage({
168
+ action: 'invalidate',
169
+ entry: { key: msg.entry.key },
170
+ cacheId: this.id,
171
+ type: 'cache-sync-message',
172
+ });
173
+ return channel.postMessage({
174
+ ...msg,
175
+ entry: {
176
+ ...msg.entry,
177
+ value: syncTabs.serialize(msg.entry.value),
178
+ },
179
+ cacheId: this.id,
180
+ type: 'cache-sync-message',
181
+ });
182
+ };
183
+ channel.onmessage = (event) => {
184
+ const msg = event.data;
185
+ if (!isSyncMessage(msg))
186
+ return;
187
+ if (msg.cacheId === this.id)
188
+ return; // ignore messages from this cache
189
+ if (msg.action === 'store') {
190
+ const value = syncTabs.deserialize(msg.entry.value);
191
+ if (value === null)
192
+ return;
193
+ this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
194
+ }
195
+ else if (msg.action === 'invalidate') {
196
+ this.invalidateInternal(msg.entry.key, true);
197
+ }
198
+ };
199
+ destroySyncTabs = () => {
200
+ channel.close();
201
+ };
202
+ }
203
+ let destroyed = false;
204
+ const destroy = () => {
205
+ if (destroyed)
206
+ return;
207
+ destroyed = true;
208
+ clearInterval(cleanupInterval);
209
+ destroySyncTabs();
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
+ });
228
+ this.destroy = destroy;
53
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
54
230
  const registry = new FinalizationRegistry((id) => {
55
- if (id === destroyId) {
56
- clearInterval(cleanupInterval);
231
+ if (id === this.id) {
232
+ destroy();
57
233
  }
58
234
  });
59
- registry.register(this, destroyId);
235
+ registry.register(this, this.id);
60
236
  }
61
237
  /** @internal */
62
238
  getInternal(key) {
@@ -115,7 +291,10 @@ class Cache {
115
291
  * @param staleTime - (Optional) The stale time for this entry, in milliseconds. Overrides the default `staleTime`.
116
292
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
117
293
  */
118
- store(key, value, staleTime = this.staleTime, ttl = this.ttl) {
294
+ store(key, value, staleTime = this.staleTime, ttl = this.ttl, persist = false) {
295
+ this.storeInternal(key, value, staleTime, ttl, false, persist);
296
+ }
297
+ storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false, persist = false) {
119
298
  const entry = this.getUntracked(key);
120
299
  if (entry) {
121
300
  clearTimeout(entry.timeout); // stop invalidation
@@ -125,18 +304,30 @@ class Cache {
125
304
  if (ttl < staleTime)
126
305
  staleTime = ttl;
127
306
  const now = Date.now();
307
+ const next = {
308
+ value,
309
+ created: entry?.created ?? now,
310
+ updated: now,
311
+ useCount: prevCount + 1,
312
+ stale: now + staleTime,
313
+ expiresAt: now + ttl,
314
+ key,
315
+ };
128
316
  this.internal.mutate((map) => {
129
317
  map.set(key, {
130
- value,
131
- created: entry?.created ?? now,
132
- useCount: prevCount + 1,
133
- stale: now + staleTime,
134
- expiresAt: now + ttl,
318
+ ...next,
135
319
  timeout: setTimeout(() => this.invalidate(key), ttl),
136
- key,
137
320
  });
138
321
  return map;
139
322
  });
323
+ if (!fromSync) {
324
+ if (persist)
325
+ this.db.then((db) => db.store(next));
326
+ this.broadcast({
327
+ action: 'store',
328
+ entry: next,
329
+ });
330
+ }
140
331
  }
141
332
  /**
142
333
  * Invalidates (removes) a cache entry.
@@ -144,6 +335,9 @@ class Cache {
144
335
  * @param key - The key of the entry to invalidate.
145
336
  */
146
337
  invalidate(key) {
338
+ this.invalidateInternal(key);
339
+ }
340
+ invalidateInternal(key, fromSync = false) {
147
341
  const entry = this.getUntracked(key);
148
342
  if (!entry)
149
343
  return;
@@ -152,6 +346,10 @@ class Cache {
152
346
  map.delete(key);
153
347
  return map;
154
348
  });
349
+ if (!fromSync) {
350
+ this.db.then((db) => db.remove(key));
351
+ this.broadcast({ action: 'invalidate', entry: { key } });
352
+ }
155
353
  }
156
354
  /** @internal */
157
355
  cleanup() {
@@ -198,9 +396,80 @@ const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
198
396
  * };
199
397
  */
200
398
  function provideQueryCache(opt) {
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
439
+ ? {
440
+ id: 'mmstack-query-cache-sync',
441
+ serialize,
442
+ deserialize,
443
+ }
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
+ });
201
470
  return {
202
471
  provide: CLIENT_CACHE_TOKEN,
203
- useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup),
472
+ useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db),
204
473
  };
205
474
  }
206
475
  class NoopCache extends Cache {
@@ -256,17 +525,7 @@ function setCacheContext(ctx = new HttpContext(), opt) {
256
525
  function getCacheContext(ctx) {
257
526
  return ctx.get(CACHE_CONTEXT);
258
527
  }
259
- function parseCacheControlHeader(req, ignoreCacheControl = false) {
260
- if (ignoreCacheControl) {
261
- return {
262
- noStore: false,
263
- noCache: false,
264
- mustRevalidate: false,
265
- immutable: false,
266
- maxAge: null,
267
- staleWhileRevalidate: null,
268
- };
269
- }
528
+ function parseCacheControlHeader(req) {
270
529
  const header = req.headers.get('Cache-Control');
271
530
  let sMaxAge = null;
272
531
  const directives = {
@@ -421,10 +680,12 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
421
680
  }
422
681
  return next(req).pipe(tap((event) => {
423
682
  if (event instanceof HttpResponse && event.ok) {
424
- const cacheControl = parseCacheControlHeader(event, opt.ignoreCacheControl);
425
- if (cacheControl.noStore)
683
+ const cacheControl = parseCacheControlHeader(event);
684
+ if (cacheControl.noStore && !opt.ignoreCacheControl)
426
685
  return;
427
- const { staleTime, ttl } = resolveTimings(cacheControl, opt.staleTime, opt.ttl);
686
+ const { staleTime, ttl } = opt.ignoreCacheControl
687
+ ? opt
688
+ : resolveTimings(cacheControl, opt.staleTime, opt.ttl);
428
689
  if (opt.ttl === 0)
429
690
  return; // no point
430
691
  const parsedResponse = opt.parse
@@ -436,7 +697,7 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
436
697
  url: event.url ?? undefined,
437
698
  })
438
699
  : event;
439
- cache.store(key, parsedResponse, staleTime, ttl);
700
+ cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
440
701
  }
441
702
  }), map((event) => {
442
703
  // handle 304 responses due to eTag/last-modified
@@ -989,7 +1250,7 @@ function queryResource(request, options) {
989
1250
  options.cache.bustBrowserCache === true;
990
1251
  const ignoreCacheControl = typeof options?.cache === 'object' &&
991
1252
  options.cache.ignoreCacheControl === true;
992
- const parse = options?.parse ?? ((val) => val);
1253
+ const persist = typeof options?.cache === 'object' && options.cache.persist === true;
993
1254
  const cachedRequest = options?.cache
994
1255
  ? computed(() => {
995
1256
  const r = stableRequest();
@@ -1003,6 +1264,7 @@ function queryResource(request, options) {
1003
1264
  key: cacheKey() ?? hashFn(r),
1004
1265
  bustBrowserCache,
1005
1266
  ignoreCacheControl,
1267
+ persist,
1006
1268
  }),
1007
1269
  };
1008
1270
  })
@@ -1074,7 +1336,7 @@ function queryResource(request, options) {
1074
1336
  body: value,
1075
1337
  status: 200,
1076
1338
  statusText: 'OK',
1077
- }));
1339
+ }), staleTime, ttl, persist);
1078
1340
  };
1079
1341
  const update = (updater) => {
1080
1342
  set(updater(untracked(resource.value)));
@@ -1133,6 +1395,7 @@ function queryResource(request, options) {
1133
1395
  }),
1134
1396
  bustBrowserCache,
1135
1397
  ignoreCacheControl,
1398
+ persist,
1136
1399
  }),
1137
1400
  headers: prefetchRequest.headers,
1138
1401
  observe: 'response',