@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 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