@lakutata/cache 3.0.0-beta.0

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.
Files changed (116) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +175 -0
  3. package/dist/cjs/cache/Cacher.d.ts +446 -0
  4. package/dist/cjs/cache/Cacher.js +523 -0
  5. package/dist/cjs/cache/exceptions/CacheDriverNotFoundException.d.ts +10 -0
  6. package/dist/cjs/cache/exceptions/CacheDriverNotFoundException.js +17 -0
  7. package/dist/cjs/cache/interfaces/CacherOptions.d.ts +42 -0
  8. package/dist/cjs/cache/interfaces/CacherOptions.js +2 -0
  9. package/dist/cjs/cache/lib/LikePrefix.d.ts +5 -0
  10. package/dist/cjs/cache/lib/LikePrefix.js +10 -0
  11. package/dist/cjs/cache/lib/LoadDriver.d.ts +6 -0
  12. package/dist/cjs/cache/lib/LoadDriver.js +66 -0
  13. package/dist/cjs/cache/lib/Serializer.d.ts +18 -0
  14. package/dist/cjs/cache/lib/Serializer.js +46 -0
  15. package/dist/cjs/cache/lib/WithTimeout.d.ts +7 -0
  16. package/dist/cjs/cache/lib/WithTimeout.js +23 -0
  17. package/dist/cjs/cache/options/FileCacheOptions.d.ts +54 -0
  18. package/dist/cjs/cache/options/FileCacheOptions.js +62 -0
  19. package/dist/cjs/cache/options/MemcacheCacheOptions.d.ts +57 -0
  20. package/dist/cjs/cache/options/MemcacheCacheOptions.js +66 -0
  21. package/dist/cjs/cache/options/MemoryCacheOptions.d.ts +39 -0
  22. package/dist/cjs/cache/options/MemoryCacheOptions.js +52 -0
  23. package/dist/cjs/cache/options/MongoCacheOptions.d.ts +72 -0
  24. package/dist/cjs/cache/options/MongoCacheOptions.js +81 -0
  25. package/dist/cjs/cache/options/MysqlCacheOptions.d.ts +73 -0
  26. package/dist/cjs/cache/options/MysqlCacheOptions.js +82 -0
  27. package/dist/cjs/cache/options/PostgresCacheOptions.d.ts +87 -0
  28. package/dist/cjs/cache/options/PostgresCacheOptions.js +92 -0
  29. package/dist/cjs/cache/options/RedisCacheOptions.d.ts +120 -0
  30. package/dist/cjs/cache/options/RedisCacheOptions.js +113 -0
  31. package/dist/cjs/cache/options/SqliteCacheOptions.d.ts +53 -0
  32. package/dist/cjs/cache/options/SqliteCacheOptions.js +61 -0
  33. package/dist/cjs/cache/stores/CacheStore.d.ts +44 -0
  34. package/dist/cjs/cache/stores/CacheStore.js +42 -0
  35. package/dist/cjs/cache/stores/CreateCacheStore.d.ts +7 -0
  36. package/dist/cjs/cache/stores/CreateCacheStore.js +37 -0
  37. package/dist/cjs/cache/stores/FileStore.d.ts +51 -0
  38. package/dist/cjs/cache/stores/FileStore.js +143 -0
  39. package/dist/cjs/cache/stores/MemcacheStore.d.ts +42 -0
  40. package/dist/cjs/cache/stores/MemcacheStore.js +69 -0
  41. package/dist/cjs/cache/stores/MemoryStore.d.ts +34 -0
  42. package/dist/cjs/cache/stores/MemoryStore.js +75 -0
  43. package/dist/cjs/cache/stores/MongoStore.d.ts +55 -0
  44. package/dist/cjs/cache/stores/MongoStore.js +72 -0
  45. package/dist/cjs/cache/stores/MysqlStore.d.ts +27 -0
  46. package/dist/cjs/cache/stores/MysqlStore.js +71 -0
  47. package/dist/cjs/cache/stores/PostgresStore.d.ts +30 -0
  48. package/dist/cjs/cache/stores/PostgresStore.js +85 -0
  49. package/dist/cjs/cache/stores/RedisStore.d.ts +58 -0
  50. package/dist/cjs/cache/stores/RedisStore.js +131 -0
  51. package/dist/cjs/cache/stores/SerializedCacheStore.d.ts +48 -0
  52. package/dist/cjs/cache/stores/SerializedCacheStore.js +80 -0
  53. package/dist/cjs/cache/stores/SqliteStore.d.ts +44 -0
  54. package/dist/cjs/cache/stores/SqliteStore.js +91 -0
  55. package/dist/cjs/cache/types/CacheStoreOptions.d.ts +15 -0
  56. package/dist/cjs/cache/types/CacheStoreOptions.js +2 -0
  57. package/dist/cjs/exports/Cache.d.ts +12 -0
  58. package/dist/cjs/exports/Cache.js +31 -0
  59. package/dist/cjs/package.json +1 -0
  60. package/dist/esm/cache/Cacher.js +518 -0
  61. package/dist/esm/cache/exceptions/CacheDriverNotFoundException.js +13 -0
  62. package/dist/esm/cache/interfaces/CacherOptions.js +1 -0
  63. package/dist/esm/cache/lib/LikePrefix.js +7 -0
  64. package/dist/esm/cache/lib/LoadDriver.js +30 -0
  65. package/dist/esm/cache/lib/Serializer.js +42 -0
  66. package/dist/esm/cache/lib/WithTimeout.js +20 -0
  67. package/dist/esm/cache/options/FileCacheOptions.js +58 -0
  68. package/dist/esm/cache/options/MemcacheCacheOptions.js +62 -0
  69. package/dist/esm/cache/options/MemoryCacheOptions.js +48 -0
  70. package/dist/esm/cache/options/MongoCacheOptions.js +77 -0
  71. package/dist/esm/cache/options/MysqlCacheOptions.js +78 -0
  72. package/dist/esm/cache/options/PostgresCacheOptions.js +88 -0
  73. package/dist/esm/cache/options/RedisCacheOptions.js +109 -0
  74. package/dist/esm/cache/options/SqliteCacheOptions.js +57 -0
  75. package/dist/esm/cache/stores/CacheStore.js +36 -0
  76. package/dist/esm/cache/stores/CreateCacheStore.js +34 -0
  77. package/dist/esm/cache/stores/FileStore.js +136 -0
  78. package/dist/esm/cache/stores/MemcacheStore.js +65 -0
  79. package/dist/esm/cache/stores/MemoryStore.js +71 -0
  80. package/dist/esm/cache/stores/MongoStore.js +68 -0
  81. package/dist/esm/cache/stores/MysqlStore.js +67 -0
  82. package/dist/esm/cache/stores/PostgresStore.js +81 -0
  83. package/dist/esm/cache/stores/RedisStore.js +127 -0
  84. package/dist/esm/cache/stores/SerializedCacheStore.js +76 -0
  85. package/dist/esm/cache/stores/SqliteStore.js +87 -0
  86. package/dist/esm/cache/types/CacheStoreOptions.js +1 -0
  87. package/dist/esm/exports/Cache.js +12 -0
  88. package/dist/types/cache/Cacher.d.ts +446 -0
  89. package/dist/types/cache/exceptions/CacheDriverNotFoundException.d.ts +10 -0
  90. package/dist/types/cache/interfaces/CacherOptions.d.ts +42 -0
  91. package/dist/types/cache/lib/LikePrefix.d.ts +5 -0
  92. package/dist/types/cache/lib/LoadDriver.d.ts +6 -0
  93. package/dist/types/cache/lib/Serializer.d.ts +18 -0
  94. package/dist/types/cache/lib/WithTimeout.d.ts +7 -0
  95. package/dist/types/cache/options/FileCacheOptions.d.ts +54 -0
  96. package/dist/types/cache/options/MemcacheCacheOptions.d.ts +57 -0
  97. package/dist/types/cache/options/MemoryCacheOptions.d.ts +39 -0
  98. package/dist/types/cache/options/MongoCacheOptions.d.ts +72 -0
  99. package/dist/types/cache/options/MysqlCacheOptions.d.ts +73 -0
  100. package/dist/types/cache/options/PostgresCacheOptions.d.ts +87 -0
  101. package/dist/types/cache/options/RedisCacheOptions.d.ts +120 -0
  102. package/dist/types/cache/options/SqliteCacheOptions.d.ts +53 -0
  103. package/dist/types/cache/stores/CacheStore.d.ts +44 -0
  104. package/dist/types/cache/stores/CreateCacheStore.d.ts +7 -0
  105. package/dist/types/cache/stores/FileStore.d.ts +51 -0
  106. package/dist/types/cache/stores/MemcacheStore.d.ts +42 -0
  107. package/dist/types/cache/stores/MemoryStore.d.ts +34 -0
  108. package/dist/types/cache/stores/MongoStore.d.ts +55 -0
  109. package/dist/types/cache/stores/MysqlStore.d.ts +27 -0
  110. package/dist/types/cache/stores/PostgresStore.d.ts +30 -0
  111. package/dist/types/cache/stores/RedisStore.d.ts +58 -0
  112. package/dist/types/cache/stores/SerializedCacheStore.d.ts +48 -0
  113. package/dist/types/cache/stores/SqliteStore.d.ts +44 -0
  114. package/dist/types/cache/types/CacheStoreOptions.d.ts +15 -0
  115. package/dist/types/exports/Cache.d.ts +12 -0
  116. package/package.json +72 -0
@@ -0,0 +1,518 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { Component, DTO } from '@lakutata/core';
11
+ import { Configurable } from '@lakutata/core/decorator/di';
12
+ import { As } from '@lakutata/utils';
13
+ import { FileCacheOptions } from './options/FileCacheOptions.js';
14
+ import { MemoryCacheOptions } from './options/MemoryCacheOptions.js';
15
+ import { RedisCacheOptions } from './options/RedisCacheOptions.js';
16
+ import { MemcacheCacheOptions } from './options/MemcacheCacheOptions.js';
17
+ import { MongoCacheOptions } from './options/MongoCacheOptions.js';
18
+ import { SqliteCacheOptions } from './options/SqliteCacheOptions.js';
19
+ import { PostgresCacheOptions } from './options/PostgresCacheOptions.js';
20
+ import { MysqlCacheOptions } from './options/MysqlCacheOptions.js';
21
+ import { MemoryStore } from './stores/MemoryStore.js';
22
+ import { createCacheStore } from './stores/CreateCacheStore.js';
23
+ /**
24
+ * Build the component options of a {@link Cacher}, to register it in the `components` of an application (or a module)
25
+ * under the name of your choice. The options are validated when the component is created, not here.
26
+ * @param options The cache options: its stores, its default TTL, its refresh and write behavior. Without options, one
27
+ * in-memory store whose entries never expire.
28
+ * @returns The component options (`{class: Cacher, ...options}`).
29
+ * @example
30
+ * ```typescript
31
+ * import {Application} from 'lakutata'
32
+ * import {buildCacherOptions, type Cacher} from 'lakutata/com/cacher'
33
+ *
34
+ * Application
35
+ * .run(() => ({
36
+ * id: 'cache.app',
37
+ * name: 'Cache',
38
+ * components: {
39
+ * //An in-memory cache whose entries expire after one minute
40
+ * cache: buildCacherOptions({ttl: 60000})
41
+ * }
42
+ * }))
43
+ * .onLaunched(async (app: Application): Promise<void> => {
44
+ * const cache: Cacher = await app.getObject('cache')
45
+ * await cache.set('greeting', 'Hello')
46
+ * console.log(await cache.get<string>('greeting')) //Hello
47
+ * })
48
+ * ```
49
+ */
50
+ export const buildCacherOptions = (options) => ({
51
+ class: Cacher,
52
+ stores: options?.stores,
53
+ ttl: options?.ttl,
54
+ refreshThreshold: options?.refreshThreshold,
55
+ refreshAllStores: options?.refreshAllStores,
56
+ nonBlocking: options?.nonBlocking,
57
+ cacheId: options?.cacheId
58
+ });
59
+ /**
60
+ * Resolve with the first defined result, or undefined when none is
61
+ * @param promises
62
+ */
63
+ function firstDefined(promises) {
64
+ return new Promise((resolve) => {
65
+ let remaining = promises.length;
66
+ if (!remaining)
67
+ return resolve(undefined);
68
+ for (const promise of promises) {
69
+ //A rejected promise gives no value
70
+ promise.then((value) => {
71
+ if (value !== undefined)
72
+ return resolve(value);
73
+ if (!--remaining)
74
+ resolve(undefined);
75
+ }, () => {
76
+ if (!--remaining)
77
+ resolve(undefined);
78
+ });
79
+ }
80
+ });
81
+ }
82
+ /**
83
+ * The cache component: a key-value cache with expiration, backed by one or more stores (memory, file, Redis,
84
+ * Memcache, MongoDB, SQLite, PostgreSQL, MySQL). Register it in the `components` of an application with
85
+ * {@link buildCacherOptions} (or `{class: Cacher, ...options}`) and get it with `getObject()` or `@Inject`; it is a
86
+ * singleton. Without stores, it caches in memory.
87
+ *
88
+ * The stores connect when the component is created: when one fails to connect, the others are disconnected and the
89
+ * creation fails with its error (the application does not start). A store whose driver package is not installed
90
+ * fails with {@link CacheDriverNotFoundException}. The stores disconnect when the component is destroyed, after the
91
+ * background refreshes in progress and the file store's pending writes.
92
+ *
93
+ * With several stores, they are tiers, the first one the fastest: a read returns the entry of the first store holding
94
+ * it, a write or a deletion goes to all of them. Only {@link Cacher.wrap} copies an entry found in a lower store to
95
+ * the stores above it.
96
+ *
97
+ * The TTLs are in milliseconds: an entry whose TTL is `0` or unset (without the `ttl` option) never expires; an expired
98
+ * entry is a miss. The memory store keeps the values by reference (a cached object changed afterwards is changed in the
99
+ * cache); the other stores keep them as JSON: the `Buffer`s are kept, but a `Date` comes back as its ISO string, a
100
+ * class instance as a plain object, a `Map` or a `Set` as `{}`, and a `bigint` fails the write.
101
+ *
102
+ * A store failing to read is a miss (its error is emitted as an `'error'` event); a store failing to write or delete
103
+ * rejects the call, unless the `nonBlocking` option is set.
104
+ * @example
105
+ * ```typescript
106
+ * import {Application, Component} from 'lakutata'
107
+ * import {Inject} from 'lakutata/decorator/di'
108
+ * import {buildCacherOptions, type Cacher} from 'lakutata/com/cacher'
109
+ *
110
+ * type Product = {id: number, name: string}
111
+ *
112
+ * class Catalog extends Component {
113
+ * @Inject('cache')
114
+ * protected readonly cache: Cacher
115
+ *
116
+ * public async product(id: number): Promise<Product> {
117
+ * //Computed once, then read from the cache for 5 minutes (recomputed in the background in its last minute)
118
+ * return this.cache.wrap(`product:${id}`, async (): Promise<Product> => ({id: id, name: `Product ${id}`}), {
119
+ * ttl: 300000,
120
+ * refreshThreshold: 60000
121
+ * })
122
+ * }
123
+ *
124
+ * public async rename(id: number, name: string): Promise<void> {
125
+ * await this.cache.set(`product:${id}`, {id: id, name: name}, 300000)
126
+ * }
127
+ *
128
+ * public async remove(id: number): Promise<void> {
129
+ * await this.cache.del(`product:${id}`)
130
+ * }
131
+ * }
132
+ *
133
+ * Application
134
+ * .run(() => ({
135
+ * id: 'catalog.app',
136
+ * name: 'Catalog',
137
+ * components: {
138
+ * //A local file tier in front of Redis (the "redis" package installed)
139
+ * cache: buildCacherOptions({
140
+ * stores: [
141
+ * {type: 'file', filename: './cache/catalog.json'},
142
+ * {type: 'redis', host: '127.0.0.1', namespace: 'catalog'}
143
+ * ],
144
+ * ttl: 600000
145
+ * }),
146
+ * catalog: {class: Catalog}
147
+ * }
148
+ * }))
149
+ * .onLaunched(async (app: Application): Promise<void> => {
150
+ * const cache: Cacher = await app.getObject('cache')
151
+ * cache.on('error', (error: Error): void => console.error('Cache store error', error))
152
+ * const catalog: Catalog = await app.getObject('catalog')
153
+ * console.log(await catalog.product(1))
154
+ * })
155
+ * ```
156
+ */
157
+ export class Cacher extends Component {
158
+ #stores = [];
159
+ #wrapping = new Map();
160
+ #refreshing = new Map();
161
+ async init() {
162
+ const storeConfigs = this.stores ? Array.isArray(this.stores) ? this.stores : [this.stores] : [];
163
+ const stores = storeConfigs
164
+ .map((storeOptions) => createCacheStore(storeOptions))
165
+ .filter((store) => !!store);
166
+ if (!stores.length)
167
+ stores.push(new MemoryStore());
168
+ for (const store of stores)
169
+ store.onError = (error) => this.report(error);
170
+ const connections = await Promise.allSettled(stores.map((store) => store.connect()));
171
+ const failure = connections.find((connection) => connection.status === 'rejected');
172
+ if (failure) {
173
+ await Promise.allSettled(stores.map((store) => store.disconnect()));
174
+ throw failure.reason;
175
+ }
176
+ this.#stores = stores;
177
+ }
178
+ async destroy() {
179
+ await Promise.allSettled(this.#refreshing.values());
180
+ await Promise.all(this.#stores.map((store) => store.disconnect()));
181
+ }
182
+ /**
183
+ * Emit a store error as an `'error'` event; dropped when the event has no listener.
184
+ * @param error The error.
185
+ * @protected
186
+ */
187
+ report(error) {
188
+ if (this.listenerCount('error'))
189
+ this.emit('error', error);
190
+ }
191
+ /**
192
+ * Resolve the time to live of a value.
193
+ * @param ttl The TTL in milliseconds, a function of the value returning it, or undefined for the cache's `ttl`
194
+ * option.
195
+ * @param value The value cached.
196
+ * @returns The TTL in milliseconds, undefined when the entry never expires.
197
+ * @protected
198
+ */
199
+ resolveTTL(ttl, value) {
200
+ return (typeof ttl === 'function' ? ttl(value) : ttl) ?? this.ttl;
201
+ }
202
+ /**
203
+ * Read an entry from one store; a failing store is a miss, its error emitted as an `'error'` event.
204
+ * @param store The store.
205
+ * @param key The key.
206
+ * @returns The entry, undefined when it is missing, expired or the store failed.
207
+ * @protected
208
+ */
209
+ async readStore(store, key) {
210
+ try {
211
+ return await store.get(key);
212
+ }
213
+ catch (e) {
214
+ this.report(e);
215
+ return undefined;
216
+ }
217
+ }
218
+ /**
219
+ * Read an entry from the first store holding it, in the stores' order; with `nonBlocking` and several stores, from
220
+ * the first store answering with it.
221
+ * @param key The key.
222
+ * @returns The entry and the index of its store, undefined on a miss.
223
+ * @protected
224
+ */
225
+ async read(key) {
226
+ const stores = this.#stores;
227
+ if (this.nonBlocking && stores.length > 1)
228
+ return firstDefined(stores.map(async (store, index) => {
229
+ const entry = await this.readStore(store, key);
230
+ return entry ? { entry: entry, index: index } : undefined;
231
+ }));
232
+ for (let index = 0; index < stores.length; index++) {
233
+ const entry = await this.readStore(stores[index], key);
234
+ if (entry)
235
+ return { entry: entry, index: index };
236
+ }
237
+ return undefined;
238
+ }
239
+ /**
240
+ * Run an operation on stores, in parallel. With `nonBlocking`, it resolves at once and a failure is only passed to
241
+ * `onError`.
242
+ * @param stores The stores.
243
+ * @param operation The operation run on each store.
244
+ * @param onError Called with the first failure.
245
+ * @throws {Error} The first failure of the operation, without `nonBlocking`.
246
+ * @protected
247
+ */
248
+ async runOnStores(stores, operation, onError) {
249
+ const promise = stores.length === 1 ? operation(stores[0]) : Promise.all(stores.map(operation));
250
+ if (this.nonBlocking) {
251
+ promise.catch(onError);
252
+ return;
253
+ }
254
+ try {
255
+ await promise;
256
+ }
257
+ catch (e) {
258
+ onError(e);
259
+ throw e;
260
+ }
261
+ }
262
+ /**
263
+ * Cache a value under a key, in all the stores, replacing the previous one. A `'set'` event is emitted once
264
+ * written; when a store fails, a `'set'` event carrying the error is emitted and the call rejects.
265
+ *
266
+ * With `nonBlocking`, the call resolves and the `'set'` event is emitted before the stores are written; a failure
267
+ * then emits a second `'set'` event carrying the error.
268
+ * @param key The key.
269
+ * @param value The value. The stores other than the memory one keep it as JSON.
270
+ * @param ttl The time to live, in milliseconds; `0` never expires. Defaults to the cache's `ttl` option (never
271
+ * expires without it).
272
+ * @returns The value given.
273
+ * @throws {Error} The failure of a store (its driver's error, or a `TypeError` for a value JSON cannot write),
274
+ * without `nonBlocking`.
275
+ */
276
+ async set(key, value, ttl) {
277
+ ttl = ttl ?? this.ttl;
278
+ await this.runOnStores(this.#stores, (store) => store.set(key, value, ttl), (error) => this.emit('set', { key: key, value: value, error: error }));
279
+ this.emit('set', { key: key, value: value });
280
+ return value;
281
+ }
282
+ /**
283
+ * Cache several values, in all the stores (in one batch per store where the store allows it). No `'set'` event is
284
+ * emitted; a failure is emitted as an `'error'` event and rejects the call (unless `nonBlocking`).
285
+ * @param options The entries; an entry without `ttl` gets the cache's `ttl` option.
286
+ * @returns The entries written, their TTL resolved.
287
+ * @throws {Error} The failure of a store, without `nonBlocking`.
288
+ */
289
+ async multipleSet(options) {
290
+ const list = options.map((option) => ({ key: option.key, value: option.value, ttl: option.ttl ?? this.ttl }));
291
+ await this.runOnStores(this.#stores, (store) => store.setMany(list), (error) => this.report(error));
292
+ return list;
293
+ }
294
+ /**
295
+ * Read the value of a key, from the first store holding it. It does not copy the value to the stores above (see
296
+ * {@link Cacher.wrap}). A failing store is a miss (its error emitted as an `'error'` event): it never rejects.
297
+ * @param key The key.
298
+ * @returns The value; undefined when the key is missing or expired (or when undefined was cached).
299
+ */
300
+ async get(key) {
301
+ if (this.#stores.length === 1)
302
+ return As((await this.readStore(this.#stores[0], key))?.value);
303
+ return As((await this.read(key))?.entry.value);
304
+ }
305
+ /**
306
+ * Read the values of several keys; each key from the first store holding it (the missing keys only are asked to
307
+ * the next store). A failing store misses all its keys (its error emitted as an `'error'` event): it never rejects.
308
+ * @param keys The keys.
309
+ * @returns The values, in the order of the keys: undefined for the missing and expired ones.
310
+ */
311
+ async multipleGet(keys) {
312
+ const readMany = async (store, storeKeys) => {
313
+ try {
314
+ return await store.getMany(storeKeys);
315
+ }
316
+ catch (e) {
317
+ this.report(e);
318
+ return [];
319
+ }
320
+ };
321
+ const stores = this.#stores;
322
+ if (this.nonBlocking && stores.length > 1) {
323
+ const results = stores.map((store) => readMany(store, keys));
324
+ return Promise.all(keys.map(async (key, index) => (await firstDefined(results.map(async (result) => (await result)[index])))?.value));
325
+ }
326
+ const values = new Array(keys.length).fill(undefined);
327
+ let missing = keys.map((key, index) => index);
328
+ for (const store of stores) {
329
+ const entries = await readMany(store, missing.map((index) => keys[index]));
330
+ missing = missing.filter((index, position) => {
331
+ if (!entries[position])
332
+ return true;
333
+ values[index] = entries[position].value;
334
+ return false;
335
+ });
336
+ if (!missing.length)
337
+ break;
338
+ }
339
+ return values;
340
+ }
341
+ /**
342
+ * Read when a key expires. Despite its name, it returns a point in time, not a remaining duration: compute the
343
+ * time left with `expires - Date.now()`.
344
+ * @param key The key.
345
+ * @returns The expiration time, in milliseconds since the epoch; `-1` when the key is missing, expired or never
346
+ * expires.
347
+ */
348
+ async getTTL(key) {
349
+ return (await this.read(key))?.entry.expires ?? -1;
350
+ }
351
+ /**
352
+ * Read the value of a key, or compute it and cache it when it is missing or expired (the cache-aside pattern).
353
+ *
354
+ * - The concurrent calls on the same key share one call and its result, whatever their `fn` and options.
355
+ * - A value found in a lower store is copied to the stores above it, with the time it has left (or the `ttl` of
356
+ * the call when it never expires).
357
+ * - With a refresh threshold, a cached value with less time to live than the threshold is returned at once and
358
+ * recomputed in the background (one refresh per key at a time), then written to its store and the stores above
359
+ * (to all the stores with `refreshAllStores`); a `'refresh'` event reports the new value or the failure, the
360
+ * cached value kept then. A threshold higher than the TTL refreshes the value on each call.
361
+ * - A computed value is written with {@link Cacher.set} (a `'set'` event); `undefined` is cached too.
362
+ * @param key The key.
363
+ * @param fn Computes the value when it is missing; its failure rejects the call and nothing is cached.
364
+ * @param options The time to live in milliseconds (`0` never expires), or the TTL and the refresh threshold.
365
+ * Default to the cache's `ttl` and `refreshThreshold` options.
366
+ * @returns The cached or computed value.
367
+ * @throws {Error} The error thrown by `fn`, or the failure of a store writing the computed value (without
368
+ * `nonBlocking`).
369
+ * @example
370
+ * ```typescript
371
+ * import type {Cacher} from 'lakutata/com/cacher'
372
+ *
373
+ * declare const cache: Cacher
374
+ * declare function loadRates(): Promise<Record<string, number>>
375
+ *
376
+ * //Loaded at most once per hour; in its last 5 minutes, served while reloaded in the background
377
+ * const rates: Record<string, number> = await cache.wrap('rates', loadRates, {ttl: 3600000, refreshThreshold: 300000})
378
+ * //A TTL depending on the value
379
+ * const user: {id: number, guest: boolean} = await cache.wrap('user:1', async () => ({id: 1, guest: true}), {
380
+ * ttl: (value: {id: number, guest: boolean}): number => value.guest ? 60000 : 3600000
381
+ * })
382
+ * ```
383
+ */
384
+ async wrap(key, fn, options) {
385
+ const pending = this.#wrapping.get(key);
386
+ if (pending)
387
+ return pending;
388
+ const promise = this.computeWrap(key, fn, typeof options === 'object' ? options : { ttl: options })
389
+ .finally(() => this.#wrapping.delete(key));
390
+ this.#wrapping.set(key, promise);
391
+ return promise;
392
+ }
393
+ /**
394
+ * Run one {@link Cacher.wrap} call: read the key, compute and write it on a miss, copy it to the stores above or
395
+ * start its background refresh on a hit.
396
+ * @param key The key.
397
+ * @param fn Computes the value.
398
+ * @param options The TTL and the refresh threshold.
399
+ * @returns The cached or computed value.
400
+ * @throws {Error} The error thrown by `fn`, or the failure of a store writing the computed value.
401
+ * @protected
402
+ */
403
+ async computeWrap(key, fn, options) {
404
+ const hit = await this.read(key);
405
+ if (!hit) {
406
+ const value = await fn();
407
+ return await this.set(key, value, this.resolveTTL(options.ttl, value));
408
+ }
409
+ const value = hit.entry.value;
410
+ const remaining = typeof hit.entry.expires === 'number' ? Math.max(1, hit.entry.expires - Date.now()) : undefined;
411
+ const threshold = (typeof options.refreshThreshold === 'function' ? options.refreshThreshold(value) : options.refreshThreshold) ?? this.refreshThreshold;
412
+ if (remaining !== undefined && threshold !== undefined && remaining < threshold) {
413
+ this.refresh(key, fn, options, value, hit.index);
414
+ }
415
+ else if (hit.index > 0) {
416
+ //The upper stores keep the value until it expires in the lower one
417
+ const ttl = remaining ?? this.resolveTTL(options.ttl, value);
418
+ await Promise.all(this.#stores.slice(0, hit.index).map((store) => store.set(key, value, ttl)))
419
+ .catch((error) => this.report(error));
420
+ }
421
+ return value;
422
+ }
423
+ /**
424
+ * Recompute a value in the background, unless the key is being refreshed already, and emit a `'refresh'` event
425
+ * with the new value or the failure. The component's destruction waits for it.
426
+ * @param key The key.
427
+ * @param fn Computes the value.
428
+ * @param options The TTL of the new value.
429
+ * @param current The value cached, reported by the event when the refresh fails.
430
+ * @param index The index of the store holding the value: the new value is written to it and to the stores above
431
+ * (to all the stores with `refreshAllStores`).
432
+ * @protected
433
+ */
434
+ refresh(key, fn, options, current, index) {
435
+ if (this.#refreshing.has(key))
436
+ return;
437
+ const stores = this.refreshAllStores ? this.#stores : this.#stores.slice(0, index + 1);
438
+ this.#refreshing.set(key, (async () => {
439
+ try {
440
+ const value = await fn();
441
+ const ttl = this.resolveTTL(options.ttl, value);
442
+ await Promise.all(stores.map((store) => store.set(key, value, ttl)));
443
+ this.emit('refresh', { key: key, value: value });
444
+ }
445
+ catch (e) {
446
+ this.emit('refresh', { key: key, value: current, error: e });
447
+ }
448
+ finally {
449
+ this.#refreshing.delete(key);
450
+ }
451
+ })());
452
+ }
453
+ /**
454
+ * Delete a key from all the stores. A `'del'` event is emitted once deleted; when a store fails, a `'del'` event
455
+ * carrying the error is emitted and the call rejects. With `nonBlocking`, it resolves and emits the `'del'` event
456
+ * before the stores are done, a failure emitting a second `'del'` event carrying the error.
457
+ * @param key The key; a missing key is not an error.
458
+ * @returns Always true, whether the key existed or not.
459
+ * @throws {Error} The failure of a store, without `nonBlocking`.
460
+ */
461
+ async del(key) {
462
+ await this.runOnStores(this.#stores, (store) => store.delete(key), (error) => this.emit('del', { key: key, error: error }));
463
+ this.emit('del', { key: key });
464
+ return true;
465
+ }
466
+ /**
467
+ * Delete several keys from all the stores. No `'del'` event is emitted; a failure is emitted as an `'error'` event
468
+ * and rejects the call (unless `nonBlocking`).
469
+ * @param keys The keys; the missing ones are not an error.
470
+ * @returns Always true, whether the keys existed or not.
471
+ * @throws {Error} The failure of a store, without `nonBlocking`.
472
+ */
473
+ async multipleDel(keys) {
474
+ await this.runOnStores(this.#stores, (store) => store.deleteMany(keys), (error) => this.report(error));
475
+ return true;
476
+ }
477
+ /**
478
+ * Delete all the entries of each store: the entries of its namespace when it has one, otherwise all its entries
479
+ * (except, in Redis, the keys containing the `keyPrefixSeparator`, which belong to the namespaces). A Memcache
480
+ * store flushes the whole server, its namespace ignored. A `'clear'` event is emitted once done; when a store fails,
481
+ * a `'clear'` event carrying the error is emitted and the call rejects (unless `nonBlocking`, which resolves and
482
+ * emits the event at once).
483
+ * @returns Always true.
484
+ * @throws {Error} The failure of a store, without `nonBlocking`.
485
+ */
486
+ async clear() {
487
+ await this.runOnStores(this.#stores, (store) => store.clear(), (error) => this.emit('clear', error));
488
+ this.emit('clear');
489
+ return true;
490
+ }
491
+ on(event, listener) {
492
+ return super.on(event, listener);
493
+ }
494
+ }
495
+ __decorate([
496
+ Configurable(DTO.Alternatives(DTO.Array(DTO.Alternatives(MemoryCacheOptions.Schema(), FileCacheOptions.Schema(), RedisCacheOptions.Schema(), MemcacheCacheOptions.Schema(), MongoCacheOptions.Schema(), SqliteCacheOptions.Schema(), PostgresCacheOptions.Schema(), MysqlCacheOptions.Schema())), MemoryCacheOptions.Schema(), FileCacheOptions.Schema(), RedisCacheOptions.Schema(), MemcacheCacheOptions.Schema(), MongoCacheOptions.Schema(), SqliteCacheOptions.Schema(), PostgresCacheOptions.Schema(), MysqlCacheOptions.Schema()).optional()),
497
+ __metadata("design:type", Object)
498
+ ], Cacher.prototype, "stores", void 0);
499
+ __decorate([
500
+ Configurable(DTO.Number().positive().integer().optional()),
501
+ __metadata("design:type", Number)
502
+ ], Cacher.prototype, "ttl", void 0);
503
+ __decorate([
504
+ Configurable(DTO.Number().positive().integer().optional()),
505
+ __metadata("design:type", Number)
506
+ ], Cacher.prototype, "refreshThreshold", void 0);
507
+ __decorate([
508
+ Configurable(DTO.Boolean().optional()),
509
+ __metadata("design:type", Boolean)
510
+ ], Cacher.prototype, "refreshAllStores", void 0);
511
+ __decorate([
512
+ Configurable(DTO.Boolean().optional()),
513
+ __metadata("design:type", Boolean)
514
+ ], Cacher.prototype, "nonBlocking", void 0);
515
+ __decorate([
516
+ Configurable(DTO.String().optional()),
517
+ __metadata("design:type", String)
518
+ ], Cacher.prototype, "cacheId", void 0);
@@ -0,0 +1,13 @@
1
+ import { Exception } from '@lakutata/core';
2
+ /**
3
+ * Thrown when the cache starts, by a store whose driver package is not installed: `redis` (or `@redis/client`) for
4
+ * Redis, `memjs` for Memcache, `mongodb` for MongoDB, `sqlite3` for SQLite, `pg` for PostgreSQL, `mysql2` for MySQL.
5
+ * Its message names the package to install. It fails the cache component's creation (and so the application's
6
+ * start). Its `errno` is `'E_CACHE_DRIVER_NOT_FOUND'`.
7
+ */
8
+ export class CacheDriverNotFoundException extends Exception {
9
+ constructor() {
10
+ super(...arguments);
11
+ this.errno = 'E_CACHE_DRIVER_NOT_FOUND';
12
+ }
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The LIKE pattern (with the "!" escape character) of the keys in a namespace
3
+ * @param namespace
4
+ */
5
+ export function likePrefix(namespace) {
6
+ return `${namespace.replace(/[!%_]/g, '!$&')}:%`;
7
+ }
@@ -0,0 +1,30 @@
1
+ import { CacheDriverNotFoundException } from '../exceptions/CacheDriverNotFoundException.js';
2
+ /**
3
+ * Whether an error reports the package itself as missing
4
+ * @param error
5
+ * @param packageName
6
+ */
7
+ function isPackageMissing(error, packageName) {
8
+ const { code, message } = error;
9
+ if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND')
10
+ return false;
11
+ return !!message && (message.includes(`'${packageName}'`) || message.includes(`"${packageName}"`));
12
+ }
13
+ /**
14
+ * Load the driver package of a store, the first installed of the candidates
15
+ * @param member a member of the driver API, to find it in the ESM namespace or the CommonJS exports
16
+ * @param packageNames
17
+ */
18
+ export async function loadDriver(member, ...packageNames) {
19
+ for (const packageName of packageNames) {
20
+ try {
21
+ const driver = await import(packageName);
22
+ return (driver[member] === undefined && driver.default ? driver.default : driver);
23
+ }
24
+ catch (e) {
25
+ if (!isPackageMissing(e, packageName))
26
+ throw e;
27
+ }
28
+ }
29
+ throw new CacheDriverNotFoundException('Package "{packageName}" is required for this driver. Run "npm install {packageName}".', { packageName: packageNames[0] });
30
+ }
@@ -0,0 +1,42 @@
1
+ import { Buffer } from 'node:buffer';
2
+ const COLON = 58;
3
+ const BASE64_PREFIX = ':base64:';
4
+ //A string value starting with a colon follows an unquoted ":", "[" or ","
5
+ const ESCAPED_STRING = /[:[,]\s*":/;
6
+ /**
7
+ * Encode the values the JSON form cannot keep
8
+ * @param this
9
+ * @param key
10
+ * @param value
11
+ */
12
+ function replacer(key, value) {
13
+ if (typeof value === 'string')
14
+ return value.charCodeAt(0) === COLON ? `:${value}` : value;
15
+ if (value !== null && typeof value === 'object' && Buffer.isBuffer(this[key]))
16
+ return `${BASE64_PREFIX}${this[key].toString('base64')}`;
17
+ return value;
18
+ }
19
+ /**
20
+ * Decode the values encoded by the replacer
21
+ * @param key
22
+ * @param value
23
+ */
24
+ function reviver(key, value) {
25
+ if (typeof value !== 'string' || value.charCodeAt(0) !== COLON)
26
+ return value;
27
+ return value.startsWith(BASE64_PREFIX) ? Buffer.from(value.slice(BASE64_PREFIX.length), 'base64') : value.slice(1);
28
+ }
29
+ /**
30
+ * Serialize data
31
+ * @param data
32
+ */
33
+ export function serialize(data) {
34
+ return JSON.stringify(data, replacer) ?? 'null';
35
+ }
36
+ /**
37
+ * Deserialize data
38
+ * @param text
39
+ */
40
+ export function deserialize(text) {
41
+ return ESCAPED_STRING.test(text) ? JSON.parse(text, reviver) : JSON.parse(text);
42
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Reject a promise which does not settle in time
3
+ * @param promise
4
+ * @param timeout ms
5
+ * @param message
6
+ */
7
+ export async function withTimeout(promise, timeout, message) {
8
+ let timer;
9
+ try {
10
+ return await Promise.race([
11
+ promise,
12
+ new Promise((resolve, reject) => {
13
+ timer = setTimeout(() => reject(new Error(message)), timeout);
14
+ })
15
+ ]);
16
+ }
17
+ finally {
18
+ clearTimeout(timer);
19
+ }
20
+ }