@open-mercato/cache 0.3.2
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/ENV.md +173 -0
- package/README.md +177 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/service.d.ts +40 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +321 -0
- package/dist/strategies/jsonfile.d.ts +10 -0
- package/dist/strategies/jsonfile.d.ts.map +1 -0
- package/dist/strategies/jsonfile.js +205 -0
- package/dist/strategies/memory.d.ts +9 -0
- package/dist/strategies/memory.d.ts.map +1 -0
- package/dist/strategies/memory.js +166 -0
- package/dist/strategies/redis.d.ts +5 -0
- package/dist/strategies/redis.d.ts.map +1 -0
- package/dist/strategies/redis.js +388 -0
- package/dist/strategies/sqlite.d.ts +13 -0
- package/dist/strategies/sqlite.d.ts.map +1 -0
- package/dist/strategies/sqlite.js +217 -0
- package/dist/tenantContext.d.ts +4 -0
- package/dist/tenantContext.d.ts.map +1 -0
- package/dist/tenantContext.js +9 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/jest.config.js +19 -0
- package/package.json +39 -0
- package/src/__tests__/memory.strategy.test.ts +245 -0
- package/src/__tests__/service.test.ts +189 -0
- package/src/errors.ts +14 -0
- package/src/index.ts +7 -0
- package/src/service.ts +367 -0
- package/src/strategies/jsonfile.ts +249 -0
- package/src/strategies/memory.ts +185 -0
- package/src/strategies/redis.ts +443 -0
- package/src/strategies/sqlite.ts +285 -0
- package/src/tenantContext.ts +13 -0
- package/src/types.ts +100 -0
- package/tsconfig.build.json +5 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CacheDependencyUnavailableError } from '../errors';
|
|
4
|
+
/**
|
|
5
|
+
* SQLite cache strategy with tag support
|
|
6
|
+
* Persistent across process restarts, stored in a SQLite database file
|
|
7
|
+
*
|
|
8
|
+
* Uses two tables:
|
|
9
|
+
* - cache_entries: stores cache data
|
|
10
|
+
* - cache_tags: stores tag associations (many-to-many)
|
|
11
|
+
*/
|
|
12
|
+
export function createSqliteStrategy(dbPath, options) {
|
|
13
|
+
let db = null;
|
|
14
|
+
const defaultTtl = options === null || options === void 0 ? void 0 : options.defaultTtl;
|
|
15
|
+
const filePath = dbPath || process.env.CACHE_SQLITE_PATH || '.cache.db';
|
|
16
|
+
async function getDb() {
|
|
17
|
+
if (db)
|
|
18
|
+
return db;
|
|
19
|
+
try {
|
|
20
|
+
const imported = await import('better-sqlite3');
|
|
21
|
+
const Database = typeof imported === 'function' ? imported : imported.default;
|
|
22
|
+
// Ensure directory exists
|
|
23
|
+
const dir = path.dirname(filePath);
|
|
24
|
+
if (!fs.existsSync(dir)) {
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
db = new Database(filePath);
|
|
28
|
+
// Create tables
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
31
|
+
key TEXT PRIMARY KEY,
|
|
32
|
+
value TEXT NOT NULL,
|
|
33
|
+
expires_at INTEGER,
|
|
34
|
+
created_at INTEGER NOT NULL
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE TABLE IF NOT EXISTS cache_tags (
|
|
38
|
+
key TEXT NOT NULL,
|
|
39
|
+
tag TEXT NOT NULL,
|
|
40
|
+
PRIMARY KEY (key, tag),
|
|
41
|
+
FOREIGN KEY (key) REFERENCES cache_entries(key) ON DELETE CASCADE
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tags_tag ON cache_tags(tag);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at ON cache_entries(expires_at);
|
|
46
|
+
`);
|
|
47
|
+
return db;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new CacheDependencyUnavailableError('sqlite', 'better-sqlite3', error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function isExpired(expiresAt) {
|
|
54
|
+
if (expiresAt === null)
|
|
55
|
+
return false;
|
|
56
|
+
return Date.now() > expiresAt;
|
|
57
|
+
}
|
|
58
|
+
function matchPattern(key, pattern) {
|
|
59
|
+
const regexPattern = pattern
|
|
60
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
61
|
+
.replace(/\*/g, '.*')
|
|
62
|
+
.replace(/\?/g, '.');
|
|
63
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
64
|
+
return regex.test(key);
|
|
65
|
+
}
|
|
66
|
+
const get = async (key, options) => {
|
|
67
|
+
const database = await getDb();
|
|
68
|
+
const stmt = database.prepare('SELECT value, expires_at FROM cache_entries WHERE key = ?');
|
|
69
|
+
const row = stmt.get(key);
|
|
70
|
+
if (!row)
|
|
71
|
+
return null;
|
|
72
|
+
try {
|
|
73
|
+
const value = JSON.parse(row.value);
|
|
74
|
+
const expiresAt = row.expires_at;
|
|
75
|
+
if (isExpired(expiresAt)) {
|
|
76
|
+
if (options === null || options === void 0 ? void 0 : options.returnExpired) {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
// Clean up expired entry
|
|
80
|
+
await deleteKey(key);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
catch (_a) {
|
|
86
|
+
// Invalid JSON, remove it
|
|
87
|
+
await deleteKey(key);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const set = async (key, value, options) => {
|
|
92
|
+
var _a;
|
|
93
|
+
const database = await getDb();
|
|
94
|
+
const ttl = (_a = options === null || options === void 0 ? void 0 : options.ttl) !== null && _a !== void 0 ? _a : defaultTtl;
|
|
95
|
+
const tags = (options === null || options === void 0 ? void 0 : options.tags) || [];
|
|
96
|
+
const expiresAt = ttl ? Date.now() + ttl : null;
|
|
97
|
+
const createdAt = Date.now();
|
|
98
|
+
const serialized = JSON.stringify(value);
|
|
99
|
+
database.transaction(() => {
|
|
100
|
+
// Delete old tags
|
|
101
|
+
database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key);
|
|
102
|
+
// Insert or replace cache entry
|
|
103
|
+
database.prepare(`
|
|
104
|
+
INSERT OR REPLACE INTO cache_entries (key, value, expires_at, created_at)
|
|
105
|
+
VALUES (?, ?, ?, ?)
|
|
106
|
+
`).run(key, serialized, expiresAt, createdAt);
|
|
107
|
+
// Insert new tags
|
|
108
|
+
if (tags.length > 0) {
|
|
109
|
+
const insertTag = database.prepare('INSERT INTO cache_tags (key, tag) VALUES (?, ?)');
|
|
110
|
+
for (const tag of tags) {
|
|
111
|
+
insertTag.run(key, tag);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
};
|
|
116
|
+
const has = async (key) => {
|
|
117
|
+
const database = await getDb();
|
|
118
|
+
const stmt = database.prepare('SELECT expires_at FROM cache_entries WHERE key = ?');
|
|
119
|
+
const row = stmt.get(key);
|
|
120
|
+
if (!row)
|
|
121
|
+
return false;
|
|
122
|
+
if (isExpired(row.expires_at)) {
|
|
123
|
+
await deleteKey(key);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
const deleteKey = async (key) => {
|
|
129
|
+
const database = await getDb();
|
|
130
|
+
const result = database.transaction(() => {
|
|
131
|
+
database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key);
|
|
132
|
+
const info = database.prepare('DELETE FROM cache_entries WHERE key = ?').run(key);
|
|
133
|
+
return info.changes > 0;
|
|
134
|
+
})();
|
|
135
|
+
return result;
|
|
136
|
+
};
|
|
137
|
+
const deleteByTags = async (tags) => {
|
|
138
|
+
const database = await getDb();
|
|
139
|
+
// Get all unique keys that have any of the specified tags
|
|
140
|
+
const placeholders = tags.map(() => '?').join(',');
|
|
141
|
+
const stmt = database.prepare(`
|
|
142
|
+
SELECT DISTINCT key FROM cache_tags WHERE tag IN (${placeholders})
|
|
143
|
+
`);
|
|
144
|
+
const rows = stmt.all(...tags);
|
|
145
|
+
let deleted = 0;
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
const success = await deleteKey(row.key);
|
|
148
|
+
if (success)
|
|
149
|
+
deleted++;
|
|
150
|
+
}
|
|
151
|
+
return deleted;
|
|
152
|
+
};
|
|
153
|
+
const clear = async () => {
|
|
154
|
+
const database = await getDb();
|
|
155
|
+
const result = database.transaction(() => {
|
|
156
|
+
const countStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries');
|
|
157
|
+
const count = countStmt.get().count;
|
|
158
|
+
database.prepare('DELETE FROM cache_tags').run();
|
|
159
|
+
database.prepare('DELETE FROM cache_entries').run();
|
|
160
|
+
return count;
|
|
161
|
+
})();
|
|
162
|
+
return result;
|
|
163
|
+
};
|
|
164
|
+
const keys = async (pattern) => {
|
|
165
|
+
const database = await getDb();
|
|
166
|
+
const stmt = database.prepare('SELECT key FROM cache_entries');
|
|
167
|
+
const rows = stmt.all();
|
|
168
|
+
const allKeys = rows.map((row) => row.key);
|
|
169
|
+
if (!pattern)
|
|
170
|
+
return allKeys;
|
|
171
|
+
return allKeys.filter((key) => matchPattern(key, pattern));
|
|
172
|
+
};
|
|
173
|
+
const stats = async () => {
|
|
174
|
+
const database = await getDb();
|
|
175
|
+
const sizeStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries');
|
|
176
|
+
const size = sizeStmt.get().count;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
const expiredStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?');
|
|
179
|
+
const expired = expiredStmt.get(now).count;
|
|
180
|
+
return { size, expired };
|
|
181
|
+
};
|
|
182
|
+
const cleanup = async () => {
|
|
183
|
+
const database = await getDb();
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const result = database.transaction(() => {
|
|
186
|
+
// Get keys to delete
|
|
187
|
+
const stmt = database.prepare('SELECT key FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?');
|
|
188
|
+
const rows = stmt.all(now);
|
|
189
|
+
// Delete tags for expired keys
|
|
190
|
+
for (const row of rows) {
|
|
191
|
+
database.prepare('DELETE FROM cache_tags WHERE key = ?').run(row.key);
|
|
192
|
+
}
|
|
193
|
+
// Delete expired entries
|
|
194
|
+
const info = database.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now);
|
|
195
|
+
return info.changes;
|
|
196
|
+
})();
|
|
197
|
+
return result;
|
|
198
|
+
};
|
|
199
|
+
const close = async () => {
|
|
200
|
+
if (db) {
|
|
201
|
+
db.close();
|
|
202
|
+
db = null;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
return {
|
|
206
|
+
get,
|
|
207
|
+
set,
|
|
208
|
+
has,
|
|
209
|
+
delete: deleteKey,
|
|
210
|
+
deleteByTags,
|
|
211
|
+
clear,
|
|
212
|
+
keys,
|
|
213
|
+
stats,
|
|
214
|
+
cleanup,
|
|
215
|
+
close,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function runWithCacheTenant<T>(tenantId: string | null, fn: () => T): T;
|
|
2
|
+
export declare function runWithCacheTenant<T>(tenantId: string | null, fn: () => Promise<T>): Promise<T>;
|
|
3
|
+
export declare function getCurrentCacheTenant(): string | null;
|
|
4
|
+
//# sourceMappingURL=tenantContext.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tenantContext.d.ts","sourceRoot":"","sources":["../src/tenantContext.ts"],"names":[],"mappings":"AAIA,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAA;AAC9E,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;AAKhG,wBAAgB,qBAAqB,IAAI,MAAM,GAAG,IAAI,CAErD"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
const tenantStorage = new AsyncLocalStorage();
|
|
3
|
+
export function runWithCacheTenant(tenantId, fn) {
|
|
4
|
+
return tenantStorage.run(tenantId !== null && tenantId !== void 0 ? tenantId : null, fn);
|
|
5
|
+
}
|
|
6
|
+
export function getCurrentCacheTenant() {
|
|
7
|
+
var _a;
|
|
8
|
+
return (_a = tenantStorage.getStore()) !== null && _a !== void 0 ? _a : null;
|
|
9
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type CacheValue = unknown;
|
|
2
|
+
export type CacheEntry = {
|
|
3
|
+
key: string;
|
|
4
|
+
value: CacheValue;
|
|
5
|
+
tags: string[];
|
|
6
|
+
expiresAt: number | null;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
};
|
|
9
|
+
export type CacheOptions = {
|
|
10
|
+
ttl?: number;
|
|
11
|
+
tags?: string[];
|
|
12
|
+
};
|
|
13
|
+
export type CacheGetOptions = {
|
|
14
|
+
returnExpired?: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type CacheSetOptions = CacheOptions;
|
|
17
|
+
export type CacheStrategy = {
|
|
18
|
+
/**
|
|
19
|
+
* Get a value from cache
|
|
20
|
+
* @param key - Cache key
|
|
21
|
+
* @param options - Get options
|
|
22
|
+
* @returns The cached value or null if not found or expired
|
|
23
|
+
*/
|
|
24
|
+
get(key: string, options?: CacheGetOptions): Promise<CacheValue | null>;
|
|
25
|
+
/**
|
|
26
|
+
* Set a value in cache
|
|
27
|
+
* @param key - Cache key
|
|
28
|
+
* @param value - Value to cache
|
|
29
|
+
* @param options - Cache options (ttl, tags)
|
|
30
|
+
*/
|
|
31
|
+
set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Check if a key exists in cache (and is not expired)
|
|
34
|
+
* @param key - Cache key
|
|
35
|
+
* @returns true if key exists and is not expired
|
|
36
|
+
*/
|
|
37
|
+
has(key: string): Promise<boolean>;
|
|
38
|
+
/**
|
|
39
|
+
* Delete a specific key from cache
|
|
40
|
+
* @param key - Cache key
|
|
41
|
+
* @returns true if key was deleted, false if not found
|
|
42
|
+
*/
|
|
43
|
+
delete(key: string): Promise<boolean>;
|
|
44
|
+
/**
|
|
45
|
+
* Delete all keys with specified tags
|
|
46
|
+
* @param tags - Tags to match (any key with ANY of these tags will be deleted)
|
|
47
|
+
* @returns Number of keys deleted
|
|
48
|
+
*/
|
|
49
|
+
deleteByTags(tags: string[]): Promise<number>;
|
|
50
|
+
/**
|
|
51
|
+
* Clear all cache entries
|
|
52
|
+
* @returns Number of keys deleted
|
|
53
|
+
*/
|
|
54
|
+
clear(): Promise<number>;
|
|
55
|
+
/**
|
|
56
|
+
* Get all keys matching a pattern
|
|
57
|
+
* @param pattern - Pattern to match (supports wildcards: * and ?)
|
|
58
|
+
* @returns Array of matching keys
|
|
59
|
+
*/
|
|
60
|
+
keys(pattern?: string): Promise<string[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Get cache statistics
|
|
63
|
+
* @returns Statistics object
|
|
64
|
+
*/
|
|
65
|
+
stats(): Promise<{
|
|
66
|
+
size: number;
|
|
67
|
+
expired: number;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* Clean up expired entries (optional, some strategies may auto-cleanup)
|
|
71
|
+
* @returns Number of entries removed
|
|
72
|
+
*/
|
|
73
|
+
cleanup?(): Promise<number>;
|
|
74
|
+
/**
|
|
75
|
+
* Close/disconnect the cache strategy
|
|
76
|
+
*/
|
|
77
|
+
close?(): Promise<void>;
|
|
78
|
+
};
|
|
79
|
+
export type CacheServiceOptions = {
|
|
80
|
+
strategy?: 'memory' | 'redis' | 'sqlite' | 'jsonfile';
|
|
81
|
+
redisUrl?: string;
|
|
82
|
+
sqlitePath?: string;
|
|
83
|
+
jsonFilePath?: string;
|
|
84
|
+
defaultTtl?: number;
|
|
85
|
+
};
|
|
86
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,OAAO,CAAA;AAEhC,MAAM,MAAM,UAAU,GAAG;IACvB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,UAAU,CAAA;IACjB,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG,YAAY,CAAA;AAE1C,MAAM,MAAM,aAAa,GAAG;IAC1B;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAA;IAEvE;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE7E;;;;OAIG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAElC;;;;OAIG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAErC;;;;OAIG;IACH,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAE7C;;;OAGG;IACH,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;IAExB;;;;OAIG;IACH,IAAI,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;IAEzC;;;OAGG;IACH,KAAK,IAAI,OAAO,CAAC;QACf,IAAI,EAAE,MAAM,CAAA;QACZ,OAAO,EAAE,MAAM,CAAA;KAChB,CAAC,CAAA;IAEF;;;OAGG;IACH,OAAO,CAAC,IAAI,OAAO,CAAC,MAAM,CAAC,CAAA;IAE3B;;OAEG;IACH,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,EAAE,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAA;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
preset: 'ts-jest',
|
|
3
|
+
testEnvironment: 'node',
|
|
4
|
+
roots: ['<rootDir>/src'],
|
|
5
|
+
testMatch: ['**/__tests__/**/*.test.ts'],
|
|
6
|
+
transform: {
|
|
7
|
+
'^.+\\.ts$': ['ts-jest', {
|
|
8
|
+
tsconfig: {
|
|
9
|
+
esModuleInterop: true,
|
|
10
|
+
allowSyntheticDefaultImports: true,
|
|
11
|
+
},
|
|
12
|
+
}],
|
|
13
|
+
},
|
|
14
|
+
moduleNameMapper: {
|
|
15
|
+
'^@open-mercato/cache$': '<rootDir>/src/index.ts',
|
|
16
|
+
'^@open-mercato/cache/(.*)$': '<rootDir>/src/$1',
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-mercato/cache",
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Multi-strategy cache service with tag-based invalidation support",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"private": false,
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "jest"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"cache",
|
|
13
|
+
"caching",
|
|
14
|
+
"redis",
|
|
15
|
+
"sqlite",
|
|
16
|
+
"memory",
|
|
17
|
+
"tags",
|
|
18
|
+
"invalidation"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"ioredis": "^5.0.0",
|
|
22
|
+
"better-sqlite3": "^9.0.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"ioredis": {
|
|
26
|
+
"optional": true
|
|
27
|
+
},
|
|
28
|
+
"better-sqlite3": {
|
|
29
|
+
"optional": true
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
34
|
+
"@types/node": "^20.0.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { createMemoryStrategy } from '../strategies/memory'
|
|
2
|
+
import type { CacheStrategy } from '../types'
|
|
3
|
+
|
|
4
|
+
describe('Memory Cache Strategy', () => {
|
|
5
|
+
let cache: CacheStrategy
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
cache = createMemoryStrategy()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('Basic operations', () => {
|
|
12
|
+
it('should set and get a value', async () => {
|
|
13
|
+
await cache.set('key1', 'value1')
|
|
14
|
+
const value = await cache.get('key1')
|
|
15
|
+
expect(value).toBe('value1')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('should return null for non-existent key', async () => {
|
|
19
|
+
const value = await cache.get('nonexistent')
|
|
20
|
+
expect(value).toBeNull()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should store complex objects', async () => {
|
|
24
|
+
const obj = { name: 'John', age: 30, nested: { city: 'NYC' } }
|
|
25
|
+
await cache.set('user', obj)
|
|
26
|
+
const value = await cache.get('user')
|
|
27
|
+
expect(value).toEqual(obj)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should check if key exists', async () => {
|
|
31
|
+
await cache.set('key1', 'value1')
|
|
32
|
+
expect(await cache.has('key1')).toBe(true)
|
|
33
|
+
expect(await cache.has('nonexistent')).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should delete a key', async () => {
|
|
37
|
+
await cache.set('key1', 'value1')
|
|
38
|
+
const deleted = await cache.delete('key1')
|
|
39
|
+
expect(deleted).toBe(true)
|
|
40
|
+
expect(await cache.has('key1')).toBe(false)
|
|
41
|
+
expect(await cache.get('key1')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('should return false when deleting non-existent key', async () => {
|
|
45
|
+
const deleted = await cache.delete('nonexistent')
|
|
46
|
+
expect(deleted).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should overwrite existing key', async () => {
|
|
50
|
+
await cache.set('key1', 'value1')
|
|
51
|
+
await cache.set('key1', 'value2')
|
|
52
|
+
const value = await cache.get('key1')
|
|
53
|
+
expect(value).toBe('value2')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('TTL and expiration', () => {
|
|
58
|
+
it('should expire after TTL', async () => {
|
|
59
|
+
await cache.set('key1', 'value1', { ttl: 100 }) // 100ms
|
|
60
|
+
expect(await cache.get('key1')).toBe('value1')
|
|
61
|
+
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
63
|
+
expect(await cache.get('key1')).toBeNull()
|
|
64
|
+
expect(await cache.has('key1')).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('should not expire without TTL', async () => {
|
|
68
|
+
await cache.set('key1', 'value1')
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
70
|
+
expect(await cache.get('key1')).toBe('value1')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should return expired value if returnExpired is true', async () => {
|
|
74
|
+
await cache.set('key1', 'value1', { ttl: 50 })
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
76
|
+
const value = await cache.get('key1', { returnExpired: true })
|
|
77
|
+
expect(value).toBe('value1')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should use default TTL when provided', async () => {
|
|
81
|
+
const cacheWithTtl = createMemoryStrategy({ defaultTtl: 100 })
|
|
82
|
+
await cacheWithTtl.set('key1', 'value1') // Should use default TTL
|
|
83
|
+
expect(await cacheWithTtl.get('key1')).toBe('value1')
|
|
84
|
+
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
86
|
+
expect(await cacheWithTtl.get('key1')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should override default TTL with explicit TTL', async () => {
|
|
90
|
+
const cacheWithTtl = createMemoryStrategy({ defaultTtl: 100 })
|
|
91
|
+
await cacheWithTtl.set('key1', 'value1', { ttl: 200 })
|
|
92
|
+
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 150))
|
|
94
|
+
expect(await cacheWithTtl.get('key1')).toBe('value1') // Still valid
|
|
95
|
+
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
97
|
+
expect(await cacheWithTtl.get('key1')).toBeNull() // Now expired
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('Tag-based operations', () => {
|
|
102
|
+
it('should set value with tags', async () => {
|
|
103
|
+
await cache.set('user:1', { name: 'John' }, { tags: ['users', 'user:1'] })
|
|
104
|
+
const value = await cache.get('user:1')
|
|
105
|
+
expect(value).toEqual({ name: 'John' })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('should delete by single tag', async () => {
|
|
109
|
+
await cache.set('user:1', { name: 'John' }, { tags: ['users'] })
|
|
110
|
+
await cache.set('user:2', { name: 'Jane' }, { tags: ['users'] })
|
|
111
|
+
await cache.set('org:1', { name: 'ACME' }, { tags: ['organizations'] })
|
|
112
|
+
|
|
113
|
+
const deleted = await cache.deleteByTags(['users'])
|
|
114
|
+
expect(deleted).toBe(2)
|
|
115
|
+
expect(await cache.get('user:1')).toBeNull()
|
|
116
|
+
expect(await cache.get('user:2')).toBeNull()
|
|
117
|
+
expect(await cache.get('org:1')).toEqual({ name: 'ACME' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should delete by multiple tags', async () => {
|
|
121
|
+
await cache.set('user:1', { name: 'John' }, { tags: ['users', 'org:1'] })
|
|
122
|
+
await cache.set('user:2', { name: 'Jane' }, { tags: ['users', 'org:2'] })
|
|
123
|
+
await cache.set('org:1', { name: 'ACME' }, { tags: ['organizations', 'org:1'] })
|
|
124
|
+
|
|
125
|
+
const deleted = await cache.deleteByTags(['org:1'])
|
|
126
|
+
expect(deleted).toBe(2) // user:1 and org:1
|
|
127
|
+
expect(await cache.get('user:1')).toBeNull()
|
|
128
|
+
expect(await cache.get('org:1')).toBeNull()
|
|
129
|
+
expect(await cache.get('user:2')).toEqual({ name: 'Jane' })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should handle empty tags array', async () => {
|
|
133
|
+
await cache.set('user:1', { name: 'John' }, { tags: ['users'] })
|
|
134
|
+
const deleted = await cache.deleteByTags([])
|
|
135
|
+
expect(deleted).toBe(0)
|
|
136
|
+
expect(await cache.get('user:1')).toEqual({ name: 'John' })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should handle non-existent tags', async () => {
|
|
140
|
+
await cache.set('user:1', { name: 'John' }, { tags: ['users'] })
|
|
141
|
+
const deleted = await cache.deleteByTags(['nonexistent'])
|
|
142
|
+
expect(deleted).toBe(0)
|
|
143
|
+
expect(await cache.get('user:1')).toEqual({ name: 'John' })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should update tags when overwriting key', async () => {
|
|
147
|
+
await cache.set('user:1', { name: 'John' }, { tags: ['users', 'org:1'] })
|
|
148
|
+
await cache.set('user:1', { name: 'John Doe' }, { tags: ['users', 'org:2'] })
|
|
149
|
+
|
|
150
|
+
await cache.deleteByTags(['org:1'])
|
|
151
|
+
expect(await cache.get('user:1')).toEqual({ name: 'John Doe' }) // Should still exist
|
|
152
|
+
|
|
153
|
+
await cache.deleteByTags(['org:2'])
|
|
154
|
+
expect(await cache.get('user:1')).toBeNull() // Now deleted
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('Pattern matching', () => {
|
|
159
|
+
beforeEach(async () => {
|
|
160
|
+
await cache.set('user:1', 'John')
|
|
161
|
+
await cache.set('user:2', 'Jane')
|
|
162
|
+
await cache.set('org:1', 'ACME')
|
|
163
|
+
await cache.set('product:1', 'Widget')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should return all keys without pattern', async () => {
|
|
167
|
+
const keys = await cache.keys()
|
|
168
|
+
expect(keys).toHaveLength(4)
|
|
169
|
+
expect(keys).toContain('user:1')
|
|
170
|
+
expect(keys).toContain('user:2')
|
|
171
|
+
expect(keys).toContain('org:1')
|
|
172
|
+
expect(keys).toContain('product:1')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should match keys with wildcard', async () => {
|
|
176
|
+
const keys = await cache.keys('user:*')
|
|
177
|
+
expect(keys).toHaveLength(2)
|
|
178
|
+
expect(keys).toContain('user:1')
|
|
179
|
+
expect(keys).toContain('user:2')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('should match keys with question mark', async () => {
|
|
183
|
+
const keys = await cache.keys('user:?')
|
|
184
|
+
expect(keys).toHaveLength(2)
|
|
185
|
+
expect(keys).toContain('user:1')
|
|
186
|
+
expect(keys).toContain('user:2')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('should match exact key', async () => {
|
|
190
|
+
const keys = await cache.keys('org:1')
|
|
191
|
+
expect(keys).toHaveLength(1)
|
|
192
|
+
expect(keys).toContain('org:1')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should return empty array for no matches', async () => {
|
|
196
|
+
const keys = await cache.keys('nonexistent:*')
|
|
197
|
+
expect(keys).toHaveLength(0)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('Clear and cleanup', () => {
|
|
202
|
+
it('should clear all cache entries', async () => {
|
|
203
|
+
await cache.set('key1', 'value1')
|
|
204
|
+
await cache.set('key2', 'value2')
|
|
205
|
+
await cache.set('key3', 'value3')
|
|
206
|
+
|
|
207
|
+
const cleared = await cache.clear()
|
|
208
|
+
expect(cleared).toBe(3)
|
|
209
|
+
expect(await cache.keys()).toHaveLength(0)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('should cleanup expired entries', async () => {
|
|
213
|
+
await cache.set('key1', 'value1', { ttl: 50 })
|
|
214
|
+
await cache.set('key2', 'value2', { ttl: 50 })
|
|
215
|
+
await cache.set('key3', 'value3') // No TTL
|
|
216
|
+
|
|
217
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
218
|
+
|
|
219
|
+
const removed = await cache.cleanup!()
|
|
220
|
+
expect(removed).toBe(2)
|
|
221
|
+
expect(await cache.get('key1')).toBeNull()
|
|
222
|
+
expect(await cache.get('key2')).toBeNull()
|
|
223
|
+
expect(await cache.get('key3')).toBe('value3')
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('Statistics', () => {
|
|
228
|
+
it('should return correct statistics', async () => {
|
|
229
|
+
await cache.set('key1', 'value1')
|
|
230
|
+
await cache.set('key2', 'value2')
|
|
231
|
+
await cache.set('key3', 'value3', { ttl: 50 })
|
|
232
|
+
|
|
233
|
+
let stats = await cache.stats()
|
|
234
|
+
expect(stats.size).toBe(3)
|
|
235
|
+
expect(stats.expired).toBe(0)
|
|
236
|
+
|
|
237
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
238
|
+
|
|
239
|
+
stats = await cache.stats()
|
|
240
|
+
expect(stats.size).toBe(3)
|
|
241
|
+
expect(stats.expired).toBe(1)
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|