@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.
Files changed (44) hide show
  1. package/ENV.md +173 -0
  2. package/README.md +177 -0
  3. package/dist/errors.d.ts +7 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +9 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +7 -0
  9. package/dist/service.d.ts +40 -0
  10. package/dist/service.d.ts.map +1 -0
  11. package/dist/service.js +321 -0
  12. package/dist/strategies/jsonfile.d.ts +10 -0
  13. package/dist/strategies/jsonfile.d.ts.map +1 -0
  14. package/dist/strategies/jsonfile.js +205 -0
  15. package/dist/strategies/memory.d.ts +9 -0
  16. package/dist/strategies/memory.d.ts.map +1 -0
  17. package/dist/strategies/memory.js +166 -0
  18. package/dist/strategies/redis.d.ts +5 -0
  19. package/dist/strategies/redis.d.ts.map +1 -0
  20. package/dist/strategies/redis.js +388 -0
  21. package/dist/strategies/sqlite.d.ts +13 -0
  22. package/dist/strategies/sqlite.d.ts.map +1 -0
  23. package/dist/strategies/sqlite.js +217 -0
  24. package/dist/tenantContext.d.ts +4 -0
  25. package/dist/tenantContext.d.ts.map +1 -0
  26. package/dist/tenantContext.js +9 -0
  27. package/dist/types.d.ts +86 -0
  28. package/dist/types.d.ts.map +1 -0
  29. package/dist/types.js +1 -0
  30. package/jest.config.js +19 -0
  31. package/package.json +39 -0
  32. package/src/__tests__/memory.strategy.test.ts +245 -0
  33. package/src/__tests__/service.test.ts +189 -0
  34. package/src/errors.ts +14 -0
  35. package/src/index.ts +7 -0
  36. package/src/service.ts +367 -0
  37. package/src/strategies/jsonfile.ts +249 -0
  38. package/src/strategies/memory.ts +185 -0
  39. package/src/strategies/redis.ts +443 -0
  40. package/src/strategies/sqlite.ts +285 -0
  41. package/src/tenantContext.ts +13 -0
  42. package/src/types.ts +100 -0
  43. package/tsconfig.build.json +5 -0
  44. 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
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "./tsconfig.json",
4
+ "exclude": ["src/**/__tests__/**/*", "src/**/*.test.ts"]
5
+ }
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
+ }