@nixxie-cms/cache 1.0.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.
- package/LICENSE +23 -0
- package/dist/declarations/src/InMemoryCache.d.ts +22 -0
- package/dist/declarations/src/InMemoryCache.d.ts.map +1 -0
- package/dist/declarations/src/RedisCache.d.ts +24 -0
- package/dist/declarations/src/RedisCache.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +8 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +22 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-cache.cjs.d.ts +2 -0
- package/dist/nixxie-cms-cache.cjs.js +226 -0
- package/dist/nixxie-cms-cache.esm.js +220 -0
- package/package.json +38 -0
- package/src/InMemoryCache.ts +122 -0
- package/src/RedisCache.ts +132 -0
- package/src/index.ts +20 -0
- package/src/types.ts +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core';
|
|
2
|
+
import type { InMemoryCacheConfig } from "./types.js";
|
|
3
|
+
export declare class InMemoryCache implements NixxieCacheService {
|
|
4
|
+
private store;
|
|
5
|
+
private tagIndex;
|
|
6
|
+
private config;
|
|
7
|
+
private accessOrder;
|
|
8
|
+
constructor(config: InMemoryCacheConfig);
|
|
9
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
10
|
+
set(key: string, value: unknown, options?: NixxieCacheSetOptions): Promise<void>;
|
|
11
|
+
delete(key: string): Promise<void>;
|
|
12
|
+
deletePattern(pattern: string): Promise<void>;
|
|
13
|
+
deleteByTag(tag: string): Promise<void>;
|
|
14
|
+
has(key: string): Promise<boolean>;
|
|
15
|
+
wrap<T>(key: string, fn: () => Promise<T>, options?: NixxieCacheSetOptions): Promise<T>;
|
|
16
|
+
clear(): Promise<void>;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
private touch;
|
|
19
|
+
private evict;
|
|
20
|
+
private enforceLru;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=InMemoryCache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"InMemoryCache.d.ts","sourceRoot":"../../../src","sources":["InMemoryCache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,mBAAe;AAQlD,qBAAa,aAAc,YAAW,kBAAkB;IACtD,OAAO,CAAC,KAAK,CAA2B;IACxC,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,WAAW,CAAe;gBAEtB,MAAM,EAAE,mBAAmB;IAIjC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAWrD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBhF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlC,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAU7C,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASvC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIlC,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,CAAC,CAAC;IAQvF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B,OAAO,CAAC,KAAK;IAMb,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,UAAU;CAOnB"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core';
|
|
2
|
+
import type { RedisCacheConfig } from "./types.js";
|
|
3
|
+
export declare class RedisCache implements NixxieCacheService {
|
|
4
|
+
private client;
|
|
5
|
+
private prefix;
|
|
6
|
+
private config;
|
|
7
|
+
constructor(config: RedisCacheConfig);
|
|
8
|
+
private k;
|
|
9
|
+
private tagKey;
|
|
10
|
+
/** Reverse index: the set of tags a given key currently belongs to. */
|
|
11
|
+
private keyTagsKey;
|
|
12
|
+
/** Remove `key` from every tag set it currently belongs to and drop its reverse-index record. */
|
|
13
|
+
private clearTagsFor;
|
|
14
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
15
|
+
set(key: string, value: unknown, options?: NixxieCacheSetOptions): Promise<void>;
|
|
16
|
+
delete(key: string): Promise<void>;
|
|
17
|
+
deletePattern(pattern: string): Promise<void>;
|
|
18
|
+
deleteByTag(tag: string): Promise<void>;
|
|
19
|
+
has(key: string): Promise<boolean>;
|
|
20
|
+
wrap<T>(key: string, fn: () => Promise<T>, options?: NixxieCacheSetOptions): Promise<T>;
|
|
21
|
+
clear(): Promise<void>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=RedisCache.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RedisCache.d.ts","sourceRoot":"../../../src","sources":["RedisCache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AACjF,OAAO,KAAK,EAAE,gBAAgB,EAAE,mBAAe;AAuB/C,qBAAa,UAAW,YAAW,kBAAkB;IACnD,OAAO,CAAC,MAAM,CAAa;IAC3B,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,MAAM,CAAkB;gBAEpB,MAAM,EAAE,gBAAgB;IAOpC,OAAO,CAAC,CAAC;IAIT,OAAO,CAAC,MAAM;IAId,uEAAuE;IACvE,OAAO,CAAC,UAAU;IAIlB,iGAAiG;YACnF,YAAY;IASpB,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,SAAS,CAAC;IAUrD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBhF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKlC,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAK7C,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAUvC,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIlC,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,CAAC,CAAC;IAQvF,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAKtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CacheConfig } from "./types.js";
|
|
2
|
+
import { InMemoryCache } from "./InMemoryCache.js";
|
|
3
|
+
import { RedisCache } from "./RedisCache.js";
|
|
4
|
+
export declare function createCache(config: CacheConfig): InMemoryCache | RedisCache;
|
|
5
|
+
export { InMemoryCache, RedisCache };
|
|
6
|
+
export type { CacheConfig, InMemoryCacheConfig, RedisCacheConfig } from "./types.js";
|
|
7
|
+
export type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAe;AAC1C,OAAO,EAAE,aAAa,EAAE,2BAAuB;AAC/C,OAAO,EAAE,UAAU,EAAE,wBAAoB;AAEzC,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,aAAa,GAAG,UAAU,CAW3E;AAED,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,CAAA;AACpC,YAAY,EAAE,WAAW,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,mBAAe;AACjF,YAAY,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core';
|
|
2
|
+
export type { NixxieCacheService, NixxieCacheSetOptions };
|
|
3
|
+
export type InMemoryCacheConfig = {
|
|
4
|
+
store: 'memory';
|
|
5
|
+
/** Maximum number of entries (LRU eviction when exceeded). Default: 1000 */
|
|
6
|
+
max?: number;
|
|
7
|
+
/** Default TTL in milliseconds for entries without an explicit TTL. Omit for no expiry. */
|
|
8
|
+
ttl?: number;
|
|
9
|
+
};
|
|
10
|
+
export type RedisCacheConfig = {
|
|
11
|
+
store: 'redis';
|
|
12
|
+
/** Redis connection URL: redis://[:password@]host[:port][/db] */
|
|
13
|
+
url: string;
|
|
14
|
+
/** Key prefix — all cache keys are stored as `{prefix}:{key}`. Default: 'nixxie:cache' */
|
|
15
|
+
prefix?: string;
|
|
16
|
+
/** Default TTL in milliseconds. Omit for no expiry. */
|
|
17
|
+
ttl?: number;
|
|
18
|
+
/** TLS options passed to ioredis */
|
|
19
|
+
tls?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type CacheConfig = InMemoryCacheConfig | RedisCacheConfig;
|
|
22
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"../../../src","sources":["types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AAEjF,YAAY,EAAE,kBAAkB,EAAE,qBAAqB,EAAE,CAAA;AAEzD,MAAM,MAAM,mBAAmB,GAAG;IAChC,KAAK,EAAE,QAAQ,CAAA;IACf,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,2FAA2F;IAC3F,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,EAAE,OAAO,CAAA;IACd,iEAAiE;IACjE,GAAG,EAAE,MAAM,CAAA;IACX,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,uDAAuD;IACvD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,oCAAoC;IACpC,GAAG,CAAC,EAAE,OAAO,CAAA;CACd,CAAA;AAED,MAAM,MAAM,WAAW,GAAG,mBAAmB,GAAG,gBAAgB,CAAA"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1jYWNoZS5janMuZC50cyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4vZGVjbGFyYXRpb25zL3NyYy9pbmRleC5kLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBIn0=
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var _defineProperty = require('@babel/runtime/helpers/defineProperty');
|
|
6
|
+
|
|
7
|
+
class InMemoryCache {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
_defineProperty(this, "store", new Map());
|
|
10
|
+
_defineProperty(this, "tagIndex", new Map());
|
|
11
|
+
_defineProperty(this, "accessOrder", []);
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
async get(key) {
|
|
15
|
+
const entry = this.store.get(key);
|
|
16
|
+
if (!entry) return undefined;
|
|
17
|
+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
|
|
18
|
+
this.evict(key);
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
this.touch(key);
|
|
22
|
+
return entry.value;
|
|
23
|
+
}
|
|
24
|
+
async set(key, value, options) {
|
|
25
|
+
var _ref, _options$ttl, _options$tags;
|
|
26
|
+
const ttl = (_ref = (_options$ttl = options === null || options === void 0 ? void 0 : options.ttl) !== null && _options$ttl !== void 0 ? _options$ttl : this.config.ttl) !== null && _ref !== void 0 ? _ref : null;
|
|
27
|
+
const tags = (_options$tags = options === null || options === void 0 ? void 0 : options.tags) !== null && _options$tags !== void 0 ? _options$tags : [];
|
|
28
|
+
this.evict(key);
|
|
29
|
+
this.store.set(key, {
|
|
30
|
+
value,
|
|
31
|
+
expiresAt: ttl !== null ? Date.now() + ttl : null,
|
|
32
|
+
tags
|
|
33
|
+
});
|
|
34
|
+
for (const tag of tags) {
|
|
35
|
+
var _this$tagIndex$get;
|
|
36
|
+
const set = (_this$tagIndex$get = this.tagIndex.get(tag)) !== null && _this$tagIndex$get !== void 0 ? _this$tagIndex$get : new Set();
|
|
37
|
+
set.add(key);
|
|
38
|
+
this.tagIndex.set(tag, set);
|
|
39
|
+
}
|
|
40
|
+
this.accessOrder.push(key);
|
|
41
|
+
this.enforceLru();
|
|
42
|
+
}
|
|
43
|
+
async delete(key) {
|
|
44
|
+
this.evict(key);
|
|
45
|
+
}
|
|
46
|
+
async deletePattern(pattern) {
|
|
47
|
+
// Escape all regex metacharacters first, then translate the glob wildcards `*` and `?`.
|
|
48
|
+
// Otherwise keys containing `.`, `(`, `[`, `+`, etc. would over-match or throw a SyntaxError.
|
|
49
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
50
|
+
const regex = new RegExp('^' + escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.') + '$');
|
|
51
|
+
for (const key of [...this.store.keys()]) {
|
|
52
|
+
if (regex.test(key)) this.evict(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async deleteByTag(tag) {
|
|
56
|
+
const keys = this.tagIndex.get(tag);
|
|
57
|
+
if (!keys) return;
|
|
58
|
+
for (const key of [...keys]) {
|
|
59
|
+
this.evict(key);
|
|
60
|
+
}
|
|
61
|
+
this.tagIndex.delete(tag);
|
|
62
|
+
}
|
|
63
|
+
async has(key) {
|
|
64
|
+
return (await this.get(key)) !== undefined;
|
|
65
|
+
}
|
|
66
|
+
async wrap(key, fn, options) {
|
|
67
|
+
const cached = await this.get(key);
|
|
68
|
+
if (cached !== undefined) return cached;
|
|
69
|
+
const value = await fn();
|
|
70
|
+
await this.set(key, value, options);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
async clear() {
|
|
74
|
+
this.store.clear();
|
|
75
|
+
this.tagIndex.clear();
|
|
76
|
+
this.accessOrder = [];
|
|
77
|
+
}
|
|
78
|
+
async close() {
|
|
79
|
+
// Nothing to close
|
|
80
|
+
}
|
|
81
|
+
touch(key) {
|
|
82
|
+
const idx = this.accessOrder.indexOf(key);
|
|
83
|
+
if (idx !== -1) this.accessOrder.splice(idx, 1);
|
|
84
|
+
this.accessOrder.push(key);
|
|
85
|
+
}
|
|
86
|
+
evict(key) {
|
|
87
|
+
const entry = this.store.get(key);
|
|
88
|
+
if (!entry) return;
|
|
89
|
+
for (const tag of entry.tags) {
|
|
90
|
+
var _this$tagIndex$get2;
|
|
91
|
+
(_this$tagIndex$get2 = this.tagIndex.get(tag)) === null || _this$tagIndex$get2 === void 0 || _this$tagIndex$get2.delete(key);
|
|
92
|
+
}
|
|
93
|
+
this.store.delete(key);
|
|
94
|
+
const idx = this.accessOrder.indexOf(key);
|
|
95
|
+
if (idx !== -1) this.accessOrder.splice(idx, 1);
|
|
96
|
+
}
|
|
97
|
+
enforceLru() {
|
|
98
|
+
var _this$config$max;
|
|
99
|
+
const max = (_this$config$max = this.config.max) !== null && _this$config$max !== void 0 ? _this$config$max : 1000;
|
|
100
|
+
while (this.store.size > max && this.accessOrder.length > 0) {
|
|
101
|
+
const oldest = this.accessOrder[0];
|
|
102
|
+
this.evict(oldest);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getRedis() {
|
|
108
|
+
try {
|
|
109
|
+
return require('ioredis');
|
|
110
|
+
} catch {
|
|
111
|
+
throw new Error('ioredis is not installed. Run: npm install ioredis');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
class RedisCache {
|
|
115
|
+
constructor(config) {
|
|
116
|
+
var _config$prefix;
|
|
117
|
+
this.config = config;
|
|
118
|
+
this.prefix = (_config$prefix = config.prefix) !== null && _config$prefix !== void 0 ? _config$prefix : 'nixxie:cache';
|
|
119
|
+
const Redis = getRedis();
|
|
120
|
+
this.client = new Redis(config.url, config.tls ? {
|
|
121
|
+
tls: {}
|
|
122
|
+
} : {});
|
|
123
|
+
}
|
|
124
|
+
k(key) {
|
|
125
|
+
return `${this.prefix}:${key}`;
|
|
126
|
+
}
|
|
127
|
+
tagKey(tag) {
|
|
128
|
+
return `${this.prefix}:tag:${tag}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reverse index: the set of tags a given key currently belongs to. */
|
|
132
|
+
keyTagsKey(key) {
|
|
133
|
+
return `${this.prefix}:keytags:${key}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Remove `key` from every tag set it currently belongs to and drop its reverse-index record. */
|
|
137
|
+
async clearTagsFor(key) {
|
|
138
|
+
const ktKey = this.keyTagsKey(key);
|
|
139
|
+
const tags = await this.client.smembers(ktKey);
|
|
140
|
+
if (tags.length) {
|
|
141
|
+
await Promise.all(tags.map(t => this.client.srem(this.tagKey(t), key)));
|
|
142
|
+
await this.client.del(ktKey);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async get(key) {
|
|
146
|
+
const raw = await this.client.get(this.k(key));
|
|
147
|
+
if (raw === null) return undefined;
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(raw);
|
|
150
|
+
} catch {
|
|
151
|
+
return raw;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async set(key, value, options) {
|
|
155
|
+
var _options$ttl, _options$tags;
|
|
156
|
+
const serialized = JSON.stringify(value);
|
|
157
|
+
const ttlMs = (_options$ttl = options === null || options === void 0 ? void 0 : options.ttl) !== null && _options$ttl !== void 0 ? _options$ttl : this.config.ttl;
|
|
158
|
+
const rKey = this.k(key);
|
|
159
|
+
const newTags = (_options$tags = options === null || options === void 0 ? void 0 : options.tags) !== null && _options$tags !== void 0 ? _options$tags : [];
|
|
160
|
+
|
|
161
|
+
// Drop any tag associations from a previous value so overwriting with different tags
|
|
162
|
+
// doesn't leave the key in stale tag sets (which would cause incorrect deleteByTag).
|
|
163
|
+
await this.clearTagsFor(key);
|
|
164
|
+
if (ttlMs !== undefined) {
|
|
165
|
+
await this.client.setex(rKey, Math.ceil(ttlMs / 1000), serialized);
|
|
166
|
+
} else {
|
|
167
|
+
await this.client.set(rKey, serialized);
|
|
168
|
+
}
|
|
169
|
+
if (newTags.length) {
|
|
170
|
+
await Promise.all(newTags.map(tag => this.client.sadd(this.tagKey(tag), key)));
|
|
171
|
+
await this.client.sadd(this.keyTagsKey(key), ...newTags);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async delete(key) {
|
|
175
|
+
await this.client.del(this.k(key));
|
|
176
|
+
await this.clearTagsFor(key);
|
|
177
|
+
}
|
|
178
|
+
async deletePattern(pattern) {
|
|
179
|
+
const keys = await this.client.keys(this.k(pattern));
|
|
180
|
+
if (keys.length) await this.client.del(...keys);
|
|
181
|
+
}
|
|
182
|
+
async deleteByTag(tag) {
|
|
183
|
+
const members = await this.client.smembers(this.tagKey(tag));
|
|
184
|
+
if (members.length) {
|
|
185
|
+
await this.client.del(...members.map(k => this.k(k)));
|
|
186
|
+
// Remove the deleted keys from all of their (possibly other) tag sets too.
|
|
187
|
+
await Promise.all(members.map(k => this.clearTagsFor(k)));
|
|
188
|
+
}
|
|
189
|
+
await this.client.del(this.tagKey(tag));
|
|
190
|
+
}
|
|
191
|
+
async has(key) {
|
|
192
|
+
return (await this.client.exists(this.k(key))) === 1;
|
|
193
|
+
}
|
|
194
|
+
async wrap(key, fn, options) {
|
|
195
|
+
const cached = await this.get(key);
|
|
196
|
+
if (cached !== undefined) return cached;
|
|
197
|
+
const value = await fn();
|
|
198
|
+
await this.set(key, value, options);
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
async clear() {
|
|
202
|
+
const keys = await this.client.keys(`${this.prefix}:*`);
|
|
203
|
+
if (keys.length) await this.client.del(...keys);
|
|
204
|
+
}
|
|
205
|
+
async close() {
|
|
206
|
+
await this.client.quit();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function createCache(config) {
|
|
211
|
+
switch (config.store) {
|
|
212
|
+
case 'memory':
|
|
213
|
+
return new InMemoryCache(config);
|
|
214
|
+
case 'redis':
|
|
215
|
+
return new RedisCache(config);
|
|
216
|
+
default:
|
|
217
|
+
{
|
|
218
|
+
const exhaustive = config;
|
|
219
|
+
throw new Error(`Unknown cache store: ${exhaustive.store}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
exports.InMemoryCache = InMemoryCache;
|
|
225
|
+
exports.RedisCache = RedisCache;
|
|
226
|
+
exports.createCache = createCache;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import _defineProperty from '@babel/runtime/helpers/esm/defineProperty';
|
|
2
|
+
|
|
3
|
+
class InMemoryCache {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
_defineProperty(this, "store", new Map());
|
|
6
|
+
_defineProperty(this, "tagIndex", new Map());
|
|
7
|
+
_defineProperty(this, "accessOrder", []);
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
async get(key) {
|
|
11
|
+
const entry = this.store.get(key);
|
|
12
|
+
if (!entry) return undefined;
|
|
13
|
+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
|
|
14
|
+
this.evict(key);
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
this.touch(key);
|
|
18
|
+
return entry.value;
|
|
19
|
+
}
|
|
20
|
+
async set(key, value, options) {
|
|
21
|
+
var _ref, _options$ttl, _options$tags;
|
|
22
|
+
const ttl = (_ref = (_options$ttl = options === null || options === void 0 ? void 0 : options.ttl) !== null && _options$ttl !== void 0 ? _options$ttl : this.config.ttl) !== null && _ref !== void 0 ? _ref : null;
|
|
23
|
+
const tags = (_options$tags = options === null || options === void 0 ? void 0 : options.tags) !== null && _options$tags !== void 0 ? _options$tags : [];
|
|
24
|
+
this.evict(key);
|
|
25
|
+
this.store.set(key, {
|
|
26
|
+
value,
|
|
27
|
+
expiresAt: ttl !== null ? Date.now() + ttl : null,
|
|
28
|
+
tags
|
|
29
|
+
});
|
|
30
|
+
for (const tag of tags) {
|
|
31
|
+
var _this$tagIndex$get;
|
|
32
|
+
const set = (_this$tagIndex$get = this.tagIndex.get(tag)) !== null && _this$tagIndex$get !== void 0 ? _this$tagIndex$get : new Set();
|
|
33
|
+
set.add(key);
|
|
34
|
+
this.tagIndex.set(tag, set);
|
|
35
|
+
}
|
|
36
|
+
this.accessOrder.push(key);
|
|
37
|
+
this.enforceLru();
|
|
38
|
+
}
|
|
39
|
+
async delete(key) {
|
|
40
|
+
this.evict(key);
|
|
41
|
+
}
|
|
42
|
+
async deletePattern(pattern) {
|
|
43
|
+
// Escape all regex metacharacters first, then translate the glob wildcards `*` and `?`.
|
|
44
|
+
// Otherwise keys containing `.`, `(`, `[`, `+`, etc. would over-match or throw a SyntaxError.
|
|
45
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
const regex = new RegExp('^' + escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.') + '$');
|
|
47
|
+
for (const key of [...this.store.keys()]) {
|
|
48
|
+
if (regex.test(key)) this.evict(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async deleteByTag(tag) {
|
|
52
|
+
const keys = this.tagIndex.get(tag);
|
|
53
|
+
if (!keys) return;
|
|
54
|
+
for (const key of [...keys]) {
|
|
55
|
+
this.evict(key);
|
|
56
|
+
}
|
|
57
|
+
this.tagIndex.delete(tag);
|
|
58
|
+
}
|
|
59
|
+
async has(key) {
|
|
60
|
+
return (await this.get(key)) !== undefined;
|
|
61
|
+
}
|
|
62
|
+
async wrap(key, fn, options) {
|
|
63
|
+
const cached = await this.get(key);
|
|
64
|
+
if (cached !== undefined) return cached;
|
|
65
|
+
const value = await fn();
|
|
66
|
+
await this.set(key, value, options);
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
async clear() {
|
|
70
|
+
this.store.clear();
|
|
71
|
+
this.tagIndex.clear();
|
|
72
|
+
this.accessOrder = [];
|
|
73
|
+
}
|
|
74
|
+
async close() {
|
|
75
|
+
// Nothing to close
|
|
76
|
+
}
|
|
77
|
+
touch(key) {
|
|
78
|
+
const idx = this.accessOrder.indexOf(key);
|
|
79
|
+
if (idx !== -1) this.accessOrder.splice(idx, 1);
|
|
80
|
+
this.accessOrder.push(key);
|
|
81
|
+
}
|
|
82
|
+
evict(key) {
|
|
83
|
+
const entry = this.store.get(key);
|
|
84
|
+
if (!entry) return;
|
|
85
|
+
for (const tag of entry.tags) {
|
|
86
|
+
var _this$tagIndex$get2;
|
|
87
|
+
(_this$tagIndex$get2 = this.tagIndex.get(tag)) === null || _this$tagIndex$get2 === void 0 || _this$tagIndex$get2.delete(key);
|
|
88
|
+
}
|
|
89
|
+
this.store.delete(key);
|
|
90
|
+
const idx = this.accessOrder.indexOf(key);
|
|
91
|
+
if (idx !== -1) this.accessOrder.splice(idx, 1);
|
|
92
|
+
}
|
|
93
|
+
enforceLru() {
|
|
94
|
+
var _this$config$max;
|
|
95
|
+
const max = (_this$config$max = this.config.max) !== null && _this$config$max !== void 0 ? _this$config$max : 1000;
|
|
96
|
+
while (this.store.size > max && this.accessOrder.length > 0) {
|
|
97
|
+
const oldest = this.accessOrder[0];
|
|
98
|
+
this.evict(oldest);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getRedis() {
|
|
104
|
+
try {
|
|
105
|
+
return require('ioredis');
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error('ioredis is not installed. Run: npm install ioredis');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
class RedisCache {
|
|
111
|
+
constructor(config) {
|
|
112
|
+
var _config$prefix;
|
|
113
|
+
this.config = config;
|
|
114
|
+
this.prefix = (_config$prefix = config.prefix) !== null && _config$prefix !== void 0 ? _config$prefix : 'nixxie:cache';
|
|
115
|
+
const Redis = getRedis();
|
|
116
|
+
this.client = new Redis(config.url, config.tls ? {
|
|
117
|
+
tls: {}
|
|
118
|
+
} : {});
|
|
119
|
+
}
|
|
120
|
+
k(key) {
|
|
121
|
+
return `${this.prefix}:${key}`;
|
|
122
|
+
}
|
|
123
|
+
tagKey(tag) {
|
|
124
|
+
return `${this.prefix}:tag:${tag}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Reverse index: the set of tags a given key currently belongs to. */
|
|
128
|
+
keyTagsKey(key) {
|
|
129
|
+
return `${this.prefix}:keytags:${key}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Remove `key` from every tag set it currently belongs to and drop its reverse-index record. */
|
|
133
|
+
async clearTagsFor(key) {
|
|
134
|
+
const ktKey = this.keyTagsKey(key);
|
|
135
|
+
const tags = await this.client.smembers(ktKey);
|
|
136
|
+
if (tags.length) {
|
|
137
|
+
await Promise.all(tags.map(t => this.client.srem(this.tagKey(t), key)));
|
|
138
|
+
await this.client.del(ktKey);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async get(key) {
|
|
142
|
+
const raw = await this.client.get(this.k(key));
|
|
143
|
+
if (raw === null) return undefined;
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(raw);
|
|
146
|
+
} catch {
|
|
147
|
+
return raw;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async set(key, value, options) {
|
|
151
|
+
var _options$ttl, _options$tags;
|
|
152
|
+
const serialized = JSON.stringify(value);
|
|
153
|
+
const ttlMs = (_options$ttl = options === null || options === void 0 ? void 0 : options.ttl) !== null && _options$ttl !== void 0 ? _options$ttl : this.config.ttl;
|
|
154
|
+
const rKey = this.k(key);
|
|
155
|
+
const newTags = (_options$tags = options === null || options === void 0 ? void 0 : options.tags) !== null && _options$tags !== void 0 ? _options$tags : [];
|
|
156
|
+
|
|
157
|
+
// Drop any tag associations from a previous value so overwriting with different tags
|
|
158
|
+
// doesn't leave the key in stale tag sets (which would cause incorrect deleteByTag).
|
|
159
|
+
await this.clearTagsFor(key);
|
|
160
|
+
if (ttlMs !== undefined) {
|
|
161
|
+
await this.client.setex(rKey, Math.ceil(ttlMs / 1000), serialized);
|
|
162
|
+
} else {
|
|
163
|
+
await this.client.set(rKey, serialized);
|
|
164
|
+
}
|
|
165
|
+
if (newTags.length) {
|
|
166
|
+
await Promise.all(newTags.map(tag => this.client.sadd(this.tagKey(tag), key)));
|
|
167
|
+
await this.client.sadd(this.keyTagsKey(key), ...newTags);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async delete(key) {
|
|
171
|
+
await this.client.del(this.k(key));
|
|
172
|
+
await this.clearTagsFor(key);
|
|
173
|
+
}
|
|
174
|
+
async deletePattern(pattern) {
|
|
175
|
+
const keys = await this.client.keys(this.k(pattern));
|
|
176
|
+
if (keys.length) await this.client.del(...keys);
|
|
177
|
+
}
|
|
178
|
+
async deleteByTag(tag) {
|
|
179
|
+
const members = await this.client.smembers(this.tagKey(tag));
|
|
180
|
+
if (members.length) {
|
|
181
|
+
await this.client.del(...members.map(k => this.k(k)));
|
|
182
|
+
// Remove the deleted keys from all of their (possibly other) tag sets too.
|
|
183
|
+
await Promise.all(members.map(k => this.clearTagsFor(k)));
|
|
184
|
+
}
|
|
185
|
+
await this.client.del(this.tagKey(tag));
|
|
186
|
+
}
|
|
187
|
+
async has(key) {
|
|
188
|
+
return (await this.client.exists(this.k(key))) === 1;
|
|
189
|
+
}
|
|
190
|
+
async wrap(key, fn, options) {
|
|
191
|
+
const cached = await this.get(key);
|
|
192
|
+
if (cached !== undefined) return cached;
|
|
193
|
+
const value = await fn();
|
|
194
|
+
await this.set(key, value, options);
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
async clear() {
|
|
198
|
+
const keys = await this.client.keys(`${this.prefix}:*`);
|
|
199
|
+
if (keys.length) await this.client.del(...keys);
|
|
200
|
+
}
|
|
201
|
+
async close() {
|
|
202
|
+
await this.client.quit();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function createCache(config) {
|
|
207
|
+
switch (config.store) {
|
|
208
|
+
case 'memory':
|
|
209
|
+
return new InMemoryCache(config);
|
|
210
|
+
case 'redis':
|
|
211
|
+
return new RedisCache(config);
|
|
212
|
+
default:
|
|
213
|
+
{
|
|
214
|
+
const exhaustive = config;
|
|
215
|
+
throw new Error(`Unknown cache store: ${exhaustive.store}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { InMemoryCache, RedisCache, createCache };
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/cache",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-cache.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-cache.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-cache.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-cache.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-cache.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"ioredis": "^5.3.2",
|
|
20
|
+
"@types/ioredis": "^4.28.10",
|
|
21
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
25
|
+
},
|
|
26
|
+
"optionalDependencies": {
|
|
27
|
+
"ioredis": "^5.3.2"
|
|
28
|
+
},
|
|
29
|
+
"preconstruct": {
|
|
30
|
+
"entrypoints": [
|
|
31
|
+
"index.ts"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/cache"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core'
|
|
2
|
+
import type { InMemoryCacheConfig } from './types'
|
|
3
|
+
|
|
4
|
+
type Entry = {
|
|
5
|
+
value: unknown
|
|
6
|
+
expiresAt: number | null
|
|
7
|
+
tags: string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class InMemoryCache implements NixxieCacheService {
|
|
11
|
+
private store = new Map<string, Entry>()
|
|
12
|
+
private tagIndex = new Map<string, Set<string>>()
|
|
13
|
+
private config: InMemoryCacheConfig
|
|
14
|
+
private accessOrder: string[] = []
|
|
15
|
+
|
|
16
|
+
constructor(config: InMemoryCacheConfig) {
|
|
17
|
+
this.config = config
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async get<T = unknown>(key: string): Promise<T | undefined> {
|
|
21
|
+
const entry = this.store.get(key)
|
|
22
|
+
if (!entry) return undefined
|
|
23
|
+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
|
|
24
|
+
this.evict(key)
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
this.touch(key)
|
|
28
|
+
return entry.value as T
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async set(key: string, value: unknown, options?: NixxieCacheSetOptions): Promise<void> {
|
|
32
|
+
const ttl = options?.ttl ?? this.config.ttl ?? null
|
|
33
|
+
const tags = options?.tags ?? []
|
|
34
|
+
|
|
35
|
+
this.evict(key)
|
|
36
|
+
|
|
37
|
+
this.store.set(key, {
|
|
38
|
+
value,
|
|
39
|
+
expiresAt: ttl !== null ? Date.now() + ttl : null,
|
|
40
|
+
tags,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
for (const tag of tags) {
|
|
44
|
+
const set = this.tagIndex.get(tag) ?? new Set()
|
|
45
|
+
set.add(key)
|
|
46
|
+
this.tagIndex.set(tag, set)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.accessOrder.push(key)
|
|
50
|
+
this.enforceLru()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async delete(key: string): Promise<void> {
|
|
54
|
+
this.evict(key)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async deletePattern(pattern: string): Promise<void> {
|
|
58
|
+
// Escape all regex metacharacters first, then translate the glob wildcards `*` and `?`.
|
|
59
|
+
// Otherwise keys containing `.`, `(`, `[`, `+`, etc. would over-match or throw a SyntaxError.
|
|
60
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
61
|
+
const regex = new RegExp('^' + escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.') + '$')
|
|
62
|
+
for (const key of [...this.store.keys()]) {
|
|
63
|
+
if (regex.test(key)) this.evict(key)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async deleteByTag(tag: string): Promise<void> {
|
|
68
|
+
const keys = this.tagIndex.get(tag)
|
|
69
|
+
if (!keys) return
|
|
70
|
+
for (const key of [...keys]) {
|
|
71
|
+
this.evict(key)
|
|
72
|
+
}
|
|
73
|
+
this.tagIndex.delete(tag)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async has(key: string): Promise<boolean> {
|
|
77
|
+
return (await this.get(key)) !== undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async wrap<T>(key: string, fn: () => Promise<T>, options?: NixxieCacheSetOptions): Promise<T> {
|
|
81
|
+
const cached = await this.get<T>(key)
|
|
82
|
+
if (cached !== undefined) return cached
|
|
83
|
+
const value = await fn()
|
|
84
|
+
await this.set(key, value, options)
|
|
85
|
+
return value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async clear(): Promise<void> {
|
|
89
|
+
this.store.clear()
|
|
90
|
+
this.tagIndex.clear()
|
|
91
|
+
this.accessOrder = []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async close(): Promise<void> {
|
|
95
|
+
// Nothing to close
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private touch(key: string): void {
|
|
99
|
+
const idx = this.accessOrder.indexOf(key)
|
|
100
|
+
if (idx !== -1) this.accessOrder.splice(idx, 1)
|
|
101
|
+
this.accessOrder.push(key)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private evict(key: string): void {
|
|
105
|
+
const entry = this.store.get(key)
|
|
106
|
+
if (!entry) return
|
|
107
|
+
for (const tag of entry.tags) {
|
|
108
|
+
this.tagIndex.get(tag)?.delete(key)
|
|
109
|
+
}
|
|
110
|
+
this.store.delete(key)
|
|
111
|
+
const idx = this.accessOrder.indexOf(key)
|
|
112
|
+
if (idx !== -1) this.accessOrder.splice(idx, 1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private enforceLru(): void {
|
|
116
|
+
const max = this.config.max ?? 1000
|
|
117
|
+
while (this.store.size > max && this.accessOrder.length > 0) {
|
|
118
|
+
const oldest = this.accessOrder[0]
|
|
119
|
+
this.evict(oldest)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core'
|
|
2
|
+
import type { RedisCacheConfig } from './types'
|
|
3
|
+
|
|
4
|
+
type RedisClient = {
|
|
5
|
+
get(key: string): Promise<string | null>
|
|
6
|
+
set(key: string, value: string): Promise<unknown>
|
|
7
|
+
setex(key: string, seconds: number, value: string): Promise<unknown>
|
|
8
|
+
del(...keys: string[]): Promise<unknown>
|
|
9
|
+
keys(pattern: string): Promise<string[]>
|
|
10
|
+
exists(key: string): Promise<number>
|
|
11
|
+
smembers(key: string): Promise<string[]>
|
|
12
|
+
sadd(key: string, ...members: string[]): Promise<unknown>
|
|
13
|
+
srem(key: string, ...members: string[]): Promise<unknown>
|
|
14
|
+
quit(): Promise<unknown>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getRedis(): new (url: string, options?: object) => RedisClient {
|
|
18
|
+
try {
|
|
19
|
+
return require('ioredis') as new (url: string, options?: object) => RedisClient
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error('ioredis is not installed. Run: npm install ioredis')
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class RedisCache implements NixxieCacheService {
|
|
26
|
+
private client: RedisClient
|
|
27
|
+
private prefix: string
|
|
28
|
+
private config: RedisCacheConfig
|
|
29
|
+
|
|
30
|
+
constructor(config: RedisCacheConfig) {
|
|
31
|
+
this.config = config
|
|
32
|
+
this.prefix = config.prefix ?? 'nixxie:cache'
|
|
33
|
+
const Redis = getRedis()
|
|
34
|
+
this.client = new Redis(config.url, config.tls ? { tls: {} } : {})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private k(key: string): string {
|
|
38
|
+
return `${this.prefix}:${key}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private tagKey(tag: string): string {
|
|
42
|
+
return `${this.prefix}:tag:${tag}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reverse index: the set of tags a given key currently belongs to. */
|
|
46
|
+
private keyTagsKey(key: string): string {
|
|
47
|
+
return `${this.prefix}:keytags:${key}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remove `key` from every tag set it currently belongs to and drop its reverse-index record. */
|
|
51
|
+
private async clearTagsFor(key: string): Promise<void> {
|
|
52
|
+
const ktKey = this.keyTagsKey(key)
|
|
53
|
+
const tags = await this.client.smembers(ktKey)
|
|
54
|
+
if (tags.length) {
|
|
55
|
+
await Promise.all(tags.map(t => this.client.srem(this.tagKey(t), key)))
|
|
56
|
+
await this.client.del(ktKey)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async get<T = unknown>(key: string): Promise<T | undefined> {
|
|
61
|
+
const raw = await this.client.get(this.k(key))
|
|
62
|
+
if (raw === null) return undefined
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(raw) as T
|
|
65
|
+
} catch {
|
|
66
|
+
return raw as unknown as T
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async set(key: string, value: unknown, options?: NixxieCacheSetOptions): Promise<void> {
|
|
71
|
+
const serialized = JSON.stringify(value)
|
|
72
|
+
const ttlMs = options?.ttl ?? this.config.ttl
|
|
73
|
+
const rKey = this.k(key)
|
|
74
|
+
const newTags = options?.tags ?? []
|
|
75
|
+
|
|
76
|
+
// Drop any tag associations from a previous value so overwriting with different tags
|
|
77
|
+
// doesn't leave the key in stale tag sets (which would cause incorrect deleteByTag).
|
|
78
|
+
await this.clearTagsFor(key)
|
|
79
|
+
|
|
80
|
+
if (ttlMs !== undefined) {
|
|
81
|
+
await this.client.setex(rKey, Math.ceil(ttlMs / 1000), serialized)
|
|
82
|
+
} else {
|
|
83
|
+
await this.client.set(rKey, serialized)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (newTags.length) {
|
|
87
|
+
await Promise.all(newTags.map((tag: string) => this.client.sadd(this.tagKey(tag), key)))
|
|
88
|
+
await this.client.sadd(this.keyTagsKey(key), ...newTags)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async delete(key: string): Promise<void> {
|
|
93
|
+
await this.client.del(this.k(key))
|
|
94
|
+
await this.clearTagsFor(key)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async deletePattern(pattern: string): Promise<void> {
|
|
98
|
+
const keys = await this.client.keys(this.k(pattern))
|
|
99
|
+
if (keys.length) await this.client.del(...keys)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async deleteByTag(tag: string): Promise<void> {
|
|
103
|
+
const members = await this.client.smembers(this.tagKey(tag))
|
|
104
|
+
if (members.length) {
|
|
105
|
+
await this.client.del(...members.map(k => this.k(k)))
|
|
106
|
+
// Remove the deleted keys from all of their (possibly other) tag sets too.
|
|
107
|
+
await Promise.all(members.map(k => this.clearTagsFor(k)))
|
|
108
|
+
}
|
|
109
|
+
await this.client.del(this.tagKey(tag))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async has(key: string): Promise<boolean> {
|
|
113
|
+
return (await this.client.exists(this.k(key))) === 1
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async wrap<T>(key: string, fn: () => Promise<T>, options?: NixxieCacheSetOptions): Promise<T> {
|
|
117
|
+
const cached = await this.get<T>(key)
|
|
118
|
+
if (cached !== undefined) return cached
|
|
119
|
+
const value = await fn()
|
|
120
|
+
await this.set(key, value, options)
|
|
121
|
+
return value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async clear(): Promise<void> {
|
|
125
|
+
const keys = await this.client.keys(`${this.prefix}:*`)
|
|
126
|
+
if (keys.length) await this.client.del(...keys)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async close(): Promise<void> {
|
|
130
|
+
await this.client.quit()
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CacheConfig } from './types'
|
|
2
|
+
import { InMemoryCache } from './InMemoryCache'
|
|
3
|
+
import { RedisCache } from './RedisCache'
|
|
4
|
+
|
|
5
|
+
export function createCache(config: CacheConfig): InMemoryCache | RedisCache {
|
|
6
|
+
switch (config.store) {
|
|
7
|
+
case 'memory':
|
|
8
|
+
return new InMemoryCache(config)
|
|
9
|
+
case 'redis':
|
|
10
|
+
return new RedisCache(config)
|
|
11
|
+
default: {
|
|
12
|
+
const exhaustive: never = config
|
|
13
|
+
throw new Error(`Unknown cache store: ${(exhaustive as any).store}`)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { InMemoryCache, RedisCache }
|
|
19
|
+
export type { CacheConfig, InMemoryCacheConfig, RedisCacheConfig } from './types'
|
|
20
|
+
export type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NixxieCacheService, NixxieCacheSetOptions } from '@nixxie-cms/core'
|
|
2
|
+
|
|
3
|
+
export type { NixxieCacheService, NixxieCacheSetOptions }
|
|
4
|
+
|
|
5
|
+
export type InMemoryCacheConfig = {
|
|
6
|
+
store: 'memory'
|
|
7
|
+
/** Maximum number of entries (LRU eviction when exceeded). Default: 1000 */
|
|
8
|
+
max?: number
|
|
9
|
+
/** Default TTL in milliseconds for entries without an explicit TTL. Omit for no expiry. */
|
|
10
|
+
ttl?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RedisCacheConfig = {
|
|
14
|
+
store: 'redis'
|
|
15
|
+
/** Redis connection URL: redis://[:password@]host[:port][/db] */
|
|
16
|
+
url: string
|
|
17
|
+
/** Key prefix — all cache keys are stored as `{prefix}:{key}`. Default: 'nixxie:cache' */
|
|
18
|
+
prefix?: string
|
|
19
|
+
/** Default TTL in milliseconds. Omit for no expiry. */
|
|
20
|
+
ttl?: number
|
|
21
|
+
/** TLS options passed to ioredis */
|
|
22
|
+
tls?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CacheConfig = InMemoryCacheConfig | RedisCacheConfig
|