@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,285 @@
|
|
|
1
|
+
import type { CacheStrategy, CacheGetOptions, CacheSetOptions, CacheValue } from '../types'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { CacheDependencyUnavailableError } from '../errors'
|
|
5
|
+
|
|
6
|
+
type SqliteStatement<TResult = unknown> = {
|
|
7
|
+
get(...args: unknown[]): TResult | undefined
|
|
8
|
+
all(...args: unknown[]): TResult[]
|
|
9
|
+
run(...args: unknown[]): { changes: number }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SqliteTransaction<TResult = unknown> = () => TResult
|
|
13
|
+
|
|
14
|
+
type SqliteDatabase = {
|
|
15
|
+
prepare<TResult = unknown>(sql: string): SqliteStatement<TResult>
|
|
16
|
+
exec(sql: string): unknown
|
|
17
|
+
transaction<TResult>(fn: () => TResult): SqliteTransaction<TResult>
|
|
18
|
+
close(): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SqliteConstructor = new (file: string) => SqliteDatabase
|
|
22
|
+
type SqliteModule = SqliteConstructor | { default: SqliteConstructor }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* SQLite cache strategy with tag support
|
|
26
|
+
* Persistent across process restarts, stored in a SQLite database file
|
|
27
|
+
*
|
|
28
|
+
* Uses two tables:
|
|
29
|
+
* - cache_entries: stores cache data
|
|
30
|
+
* - cache_tags: stores tag associations (many-to-many)
|
|
31
|
+
*/
|
|
32
|
+
export function createSqliteStrategy(dbPath?: string, options?: { defaultTtl?: number }): CacheStrategy {
|
|
33
|
+
let db: SqliteDatabase | null = null
|
|
34
|
+
const defaultTtl = options?.defaultTtl
|
|
35
|
+
const filePath = dbPath || process.env.CACHE_SQLITE_PATH || '.cache.db'
|
|
36
|
+
|
|
37
|
+
async function getDb(): Promise<SqliteDatabase> {
|
|
38
|
+
if (db) return db
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const imported = await import('better-sqlite3') as SqliteModule
|
|
42
|
+
const Database = typeof imported === 'function' ? imported : imported.default
|
|
43
|
+
|
|
44
|
+
// Ensure directory exists
|
|
45
|
+
const dir = path.dirname(filePath)
|
|
46
|
+
if (!fs.existsSync(dir)) {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
db = new Database(filePath)
|
|
51
|
+
|
|
52
|
+
// Create tables
|
|
53
|
+
db.exec(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS cache_entries (
|
|
55
|
+
key TEXT PRIMARY KEY,
|
|
56
|
+
value TEXT NOT NULL,
|
|
57
|
+
expires_at INTEGER,
|
|
58
|
+
created_at INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS cache_tags (
|
|
62
|
+
key TEXT NOT NULL,
|
|
63
|
+
tag TEXT NOT NULL,
|
|
64
|
+
PRIMARY KEY (key, tag),
|
|
65
|
+
FOREIGN KEY (key) REFERENCES cache_entries(key) ON DELETE CASCADE
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tags_tag ON cache_tags(tag);
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_cache_entries_expires_at ON cache_entries(expires_at);
|
|
70
|
+
`)
|
|
71
|
+
|
|
72
|
+
return db
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new CacheDependencyUnavailableError('sqlite', 'better-sqlite3', error)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isExpired(expiresAt: number | null): boolean {
|
|
79
|
+
if (expiresAt === null) return false
|
|
80
|
+
return Date.now() > expiresAt
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function matchPattern(key: string, pattern: string): boolean {
|
|
84
|
+
const regexPattern = pattern
|
|
85
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
86
|
+
.replace(/\*/g, '.*')
|
|
87
|
+
.replace(/\?/g, '.')
|
|
88
|
+
const regex = new RegExp(`^${regexPattern}$`)
|
|
89
|
+
return regex.test(key)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type EntryRow = { value: string; expires_at: number | null }
|
|
93
|
+
type ExpiresRow = { expires_at: number | null }
|
|
94
|
+
type CountRow = { count: number }
|
|
95
|
+
type KeyRow = { key: string }
|
|
96
|
+
|
|
97
|
+
const get = async (key: string, options?: CacheGetOptions): Promise<CacheValue | null> => {
|
|
98
|
+
const database = await getDb()
|
|
99
|
+
|
|
100
|
+
const stmt = database.prepare('SELECT value, expires_at FROM cache_entries WHERE key = ?')
|
|
101
|
+
const row = stmt.get(key) as EntryRow | undefined
|
|
102
|
+
|
|
103
|
+
if (!row) return null
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const value = JSON.parse(row.value) as CacheValue
|
|
107
|
+
const expiresAt = row.expires_at
|
|
108
|
+
|
|
109
|
+
if (isExpired(expiresAt)) {
|
|
110
|
+
if (options?.returnExpired) {
|
|
111
|
+
return value
|
|
112
|
+
}
|
|
113
|
+
// Clean up expired entry
|
|
114
|
+
await deleteKey(key)
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return value
|
|
119
|
+
} catch {
|
|
120
|
+
// Invalid JSON, remove it
|
|
121
|
+
await deleteKey(key)
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const set = async (key: string, value: CacheValue, options?: CacheSetOptions): Promise<void> => {
|
|
127
|
+
const database = await getDb()
|
|
128
|
+
const ttl = options?.ttl ?? defaultTtl
|
|
129
|
+
const tags = options?.tags || []
|
|
130
|
+
const expiresAt = ttl ? Date.now() + ttl : null
|
|
131
|
+
const createdAt = Date.now()
|
|
132
|
+
|
|
133
|
+
const serialized = JSON.stringify(value)
|
|
134
|
+
|
|
135
|
+
database.transaction(() => {
|
|
136
|
+
// Delete old tags
|
|
137
|
+
database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)
|
|
138
|
+
|
|
139
|
+
// Insert or replace cache entry
|
|
140
|
+
database.prepare(`
|
|
141
|
+
INSERT OR REPLACE INTO cache_entries (key, value, expires_at, created_at)
|
|
142
|
+
VALUES (?, ?, ?, ?)
|
|
143
|
+
`).run(key, serialized, expiresAt, createdAt)
|
|
144
|
+
|
|
145
|
+
// Insert new tags
|
|
146
|
+
if (tags.length > 0) {
|
|
147
|
+
const insertTag = database.prepare('INSERT INTO cache_tags (key, tag) VALUES (?, ?)')
|
|
148
|
+
for (const tag of tags) {
|
|
149
|
+
insertTag.run(key, tag)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
})()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const has = async (key: string): Promise<boolean> => {
|
|
156
|
+
const database = await getDb()
|
|
157
|
+
|
|
158
|
+
const stmt = database.prepare('SELECT expires_at FROM cache_entries WHERE key = ?')
|
|
159
|
+
const row = stmt.get(key) as ExpiresRow | undefined
|
|
160
|
+
|
|
161
|
+
if (!row) return false
|
|
162
|
+
|
|
163
|
+
if (isExpired(row.expires_at)) {
|
|
164
|
+
await deleteKey(key)
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const deleteKey = async (key: string): Promise<boolean> => {
|
|
172
|
+
const database = await getDb()
|
|
173
|
+
|
|
174
|
+
const result = database.transaction(() => {
|
|
175
|
+
database.prepare('DELETE FROM cache_tags WHERE key = ?').run(key)
|
|
176
|
+
const info = database.prepare('DELETE FROM cache_entries WHERE key = ?').run(key)
|
|
177
|
+
return info.changes > 0
|
|
178
|
+
})()
|
|
179
|
+
|
|
180
|
+
return result
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const deleteByTags = async (tags: string[]): Promise<number> => {
|
|
184
|
+
const database = await getDb()
|
|
185
|
+
|
|
186
|
+
// Get all unique keys that have any of the specified tags
|
|
187
|
+
const placeholders = tags.map(() => '?').join(',')
|
|
188
|
+
const stmt = database.prepare(`
|
|
189
|
+
SELECT DISTINCT key FROM cache_tags WHERE tag IN (${placeholders})
|
|
190
|
+
`)
|
|
191
|
+
const rows = stmt.all(...tags) as KeyRow[]
|
|
192
|
+
|
|
193
|
+
let deleted = 0
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
const success = await deleteKey(row.key)
|
|
196
|
+
if (success) deleted++
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return deleted
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const clear = async (): Promise<number> => {
|
|
203
|
+
const database = await getDb()
|
|
204
|
+
|
|
205
|
+
const result = database.transaction(() => {
|
|
206
|
+
const countStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')
|
|
207
|
+
const count = (countStmt.get() as CountRow).count
|
|
208
|
+
|
|
209
|
+
database.prepare('DELETE FROM cache_tags').run()
|
|
210
|
+
database.prepare('DELETE FROM cache_entries').run()
|
|
211
|
+
|
|
212
|
+
return count
|
|
213
|
+
})()
|
|
214
|
+
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const keys = async (pattern?: string): Promise<string[]> => {
|
|
219
|
+
const database = await getDb()
|
|
220
|
+
|
|
221
|
+
const stmt = database.prepare('SELECT key FROM cache_entries')
|
|
222
|
+
const rows = stmt.all() as KeyRow[]
|
|
223
|
+
|
|
224
|
+
const allKeys = rows.map((row) => row.key)
|
|
225
|
+
|
|
226
|
+
if (!pattern) return allKeys
|
|
227
|
+
|
|
228
|
+
return allKeys.filter((key: string) => matchPattern(key, pattern))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const stats = async (): Promise<{ size: number; expired: number }> => {
|
|
232
|
+
const database = await getDb()
|
|
233
|
+
|
|
234
|
+
const sizeStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries')
|
|
235
|
+
const size = (sizeStmt.get() as CountRow).count
|
|
236
|
+
|
|
237
|
+
const now = Date.now()
|
|
238
|
+
const expiredStmt = database.prepare('SELECT COUNT(*) as count FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')
|
|
239
|
+
const expired = (expiredStmt.get(now) as CountRow).count
|
|
240
|
+
|
|
241
|
+
return { size, expired }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const cleanup = async (): Promise<number> => {
|
|
245
|
+
const database = await getDb()
|
|
246
|
+
const now = Date.now()
|
|
247
|
+
|
|
248
|
+
const result = database.transaction(() => {
|
|
249
|
+
// Get keys to delete
|
|
250
|
+
const stmt = database.prepare('SELECT key FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?')
|
|
251
|
+
const rows = stmt.all(now) as KeyRow[]
|
|
252
|
+
|
|
253
|
+
// Delete tags for expired keys
|
|
254
|
+
for (const row of rows) {
|
|
255
|
+
database.prepare('DELETE FROM cache_tags WHERE key = ?').run(row.key)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Delete expired entries
|
|
259
|
+
const info = database.prepare('DELETE FROM cache_entries WHERE expires_at IS NOT NULL AND expires_at < ?').run(now)
|
|
260
|
+
return info.changes
|
|
261
|
+
})()
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const close = async (): Promise<void> => {
|
|
267
|
+
if (db) {
|
|
268
|
+
db.close()
|
|
269
|
+
db = null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
get,
|
|
275
|
+
set,
|
|
276
|
+
has,
|
|
277
|
+
delete: deleteKey,
|
|
278
|
+
deleteByTags,
|
|
279
|
+
clear,
|
|
280
|
+
keys,
|
|
281
|
+
stats,
|
|
282
|
+
cleanup,
|
|
283
|
+
close,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
const tenantStorage = new AsyncLocalStorage<string | null>()
|
|
4
|
+
|
|
5
|
+
export function runWithCacheTenant<T>(tenantId: string | null, fn: () => T): T
|
|
6
|
+
export function runWithCacheTenant<T>(tenantId: string | null, fn: () => Promise<T>): Promise<T>
|
|
7
|
+
export function runWithCacheTenant<T>(tenantId: string | null, fn: () => T | Promise<T>): T | Promise<T> {
|
|
8
|
+
return tenantStorage.run(tenantId ?? null, fn)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getCurrentCacheTenant(): string | null {
|
|
12
|
+
return tenantStorage.getStore() ?? null
|
|
13
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export type CacheValue = unknown
|
|
2
|
+
|
|
3
|
+
export type CacheEntry = {
|
|
4
|
+
key: string
|
|
5
|
+
value: CacheValue
|
|
6
|
+
tags: string[]
|
|
7
|
+
expiresAt: number | null
|
|
8
|
+
createdAt: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type CacheOptions = {
|
|
12
|
+
ttl?: number // Time to live in milliseconds
|
|
13
|
+
tags?: string[] // Tags for invalidation
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type CacheGetOptions = {
|
|
17
|
+
returnExpired?: boolean // Return expired values (default: false)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CacheSetOptions = CacheOptions
|
|
21
|
+
|
|
22
|
+
export type CacheStrategy = {
|
|
23
|
+
/**
|
|
24
|
+
* Get a value from cache
|
|
25
|
+
* @param key - Cache key
|
|
26
|
+
* @param options - Get options
|
|
27
|
+
* @returns The cached value or null if not found or expired
|
|
28
|
+
*/
|
|
29
|
+
get(key: string, options?: CacheGetOptions): Promise<CacheValue | null>
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Set a value in cache
|
|
33
|
+
* @param key - Cache key
|
|
34
|
+
* @param value - Value to cache
|
|
35
|
+
* @param options - Cache options (ttl, tags)
|
|
36
|
+
*/
|
|
37
|
+
set(key: string, value: CacheValue, options?: CacheSetOptions): Promise<void>
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a key exists in cache (and is not expired)
|
|
41
|
+
* @param key - Cache key
|
|
42
|
+
* @returns true if key exists and is not expired
|
|
43
|
+
*/
|
|
44
|
+
has(key: string): Promise<boolean>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Delete a specific key from cache
|
|
48
|
+
* @param key - Cache key
|
|
49
|
+
* @returns true if key was deleted, false if not found
|
|
50
|
+
*/
|
|
51
|
+
delete(key: string): Promise<boolean>
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Delete all keys with specified tags
|
|
55
|
+
* @param tags - Tags to match (any key with ANY of these tags will be deleted)
|
|
56
|
+
* @returns Number of keys deleted
|
|
57
|
+
*/
|
|
58
|
+
deleteByTags(tags: string[]): Promise<number>
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clear all cache entries
|
|
62
|
+
* @returns Number of keys deleted
|
|
63
|
+
*/
|
|
64
|
+
clear(): Promise<number>
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get all keys matching a pattern
|
|
68
|
+
* @param pattern - Pattern to match (supports wildcards: * and ?)
|
|
69
|
+
* @returns Array of matching keys
|
|
70
|
+
*/
|
|
71
|
+
keys(pattern?: string): Promise<string[]>
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get cache statistics
|
|
75
|
+
* @returns Statistics object
|
|
76
|
+
*/
|
|
77
|
+
stats(): Promise<{
|
|
78
|
+
size: number // Total number of entries
|
|
79
|
+
expired: number // Number of expired entries
|
|
80
|
+
}>
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Clean up expired entries (optional, some strategies may auto-cleanup)
|
|
84
|
+
* @returns Number of entries removed
|
|
85
|
+
*/
|
|
86
|
+
cleanup?(): Promise<number>
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Close/disconnect the cache strategy
|
|
90
|
+
*/
|
|
91
|
+
close?(): Promise<void>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type CacheServiceOptions = {
|
|
95
|
+
strategy?: 'memory' | 'redis' | 'sqlite' | 'jsonfile'
|
|
96
|
+
redisUrl?: string
|
|
97
|
+
sqlitePath?: string
|
|
98
|
+
jsonFilePath?: string
|
|
99
|
+
defaultTtl?: number
|
|
100
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"extends": "../../tsconfig.json",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"noEmit": false,
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*"]
|
|
12
|
+
}
|